Compare commits

..

2 Commits

Author SHA1 Message Date
core-fe ac7c395855 fix(canvas/ContextMenu): prevent React error #185 by moving hasChildren derivation out of Zustand selector
ContextMenu used `.some()` inside its Zustand selector to compute hasChildren.
Zustand's useSyncExternalStore calls the selector on every snapshot; `.some()`
returns a new boolean each time, which React 19's stricter comparison
and the re-render side-effects from the store subscription created a
feedback loop on mobile Chat tab mount → React error #185
("Maximum update depth exceeded").

Fix: select the stable `nodes` array once, derive children via useMemo
outside the store subscription. Also removes the inline `getState().nodes.filter()`
call in handleDelete in favour of the memoized children.

Regression tests (2 cases):
- setPendingDelete receives correct children array when workspace has children
- setPendingDelete hasChildren=false and empty children when no children

Refs: #651

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
core-fe 3edb68ab77 test(canvas/lib): add isExternalLikeRuntime coverage (16 cases)
Mirrors the backend isExternalLikeRuntime() contract so both sides agree
on which runtimes are external-like (no platform container, no Files/Terminal tabs).

Cases: "external", "kimi", "kimi-cli" → true; all other runtimes,
undefined, null, empty string → false. Case-sensitivity verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
147 changed files with 1129 additions and 12748 deletions
+15 -70
View File
@@ -47,15 +47,6 @@ REQUIRED_CONTEXTS_RAW = _env(
"sop-checklist / all-items-acked (pull_request)"
),
)
# Required contexts for push (main/staging) runs. The push CI uses the same
# aggregator names with " (push)" suffix. Checking these explicitly instead of
# the combined state avoids false-pause when non-blocking jobs (e.g. Platform
# Go with continue-on-error: true due to mc#774) have failed — their failures
# pollute the combined state but do not block merges.
PUSH_REQUIRED_CONTEXTS_RAW = _env(
"PUSH_REQUIRED_CONTEXTS",
default="CI / all-required (push)",
)
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
@@ -127,24 +118,16 @@ def required_contexts(raw: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part.strip()]
def push_required_contexts() -> list[str]:
"""Required contexts for push (branch) CI runs. See PUSH_REQUIRED_CONTEXTS_RAW."""
return required_contexts(PUSH_REQUIRED_CONTEXTS_RAW)
def status_state(status: dict) -> str:
return str(status.get("status") or status.get("state") or "").lower()
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
# Gitea /statuses endpoint returns entries in ascending id order (oldest
# first). We need the LAST occurrence of each context, so iterate in
# reverse to prefer newer entries.
latest: dict[str, dict] = {}
for status in reversed(statuses):
for status in statuses:
context = status.get("context")
if isinstance(context, str):
latest[context] = status # overwrite: reverse order → newest wins
if isinstance(context, str) and context not in latest:
latest[context] = status
return latest
@@ -210,23 +193,16 @@ def evaluate_merge_readiness(
required_contexts: list[str],
pr_has_current_base: bool,
) -> MergeDecision:
# Check push-required contexts explicitly instead of combined state.
# Combined state can be "failure" due to non-blocking jobs
# (continue-on-error: true) that don't actually gate merges.
# CI / all-required (push) is the authoritative gate — it respects
# continue-on-error and correctly aggregates all blocking failures.
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
return MergeDecision(False, "pause", "main required contexts not green: " + ", ".join(main_bad))
main_state = str(main_status.get("state") or "").lower()
if main_state != "success":
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
if not pr_has_current_base:
return MergeDecision(False, "update", "PR head does not contain current main")
# Check explicit required contexts instead of combined state. Combined state
# can be "failure" due to non-blocking jobs with continue-on-error: true
# (e.g. publish-runtime-autobump/pr-validate, qa-review on stale tokens).
# The required_contexts list is the authoritative gate — it includes only
# the checks that actually block merges.
pr_state = str(pr_status.get("state") or "").lower()
if pr_state != "success":
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
if not ok:
@@ -244,37 +220,10 @@ def get_branch_head(branch: str) -> str:
def get_combined_status(sha: str) -> dict:
"""Combined status + all individual statuses for `sha`.
The /status endpoint caps the `statuses` array at 30 entries (Gitea
default page size), so we fetch the full list via /statuses with a
higher limit. The combined `state` still comes from /status.
"""
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(combined, dict):
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(body, dict):
raise ApiError(f"status for {sha} response not object")
# Fetch full statuses list; 200 covers >99% of real-world runs.
# The list is ordered ascending by id (oldest first) — callers must
# iterate in reverse to get the newest entry per context.
# Best-effort: large repos (main with 550+ statuses) may time out.
# On timeout, fall back to the statuses[] already in the combined
# response (usually 30 entries — enough for most PRs, enough for
# main's early push-required contexts).
try:
_, all_statuses = api(
"GET",
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
query={"limit": "50"},
)
if isinstance(all_statuses, list):
combined["statuses"] = all_statuses
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
# URLError covers network-level failures (DNS, refused, timeout).
# TimeoutError and OSError cover socket-level timeouts.
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
# Fall back to the statuses[] already in the combined response.
pass
return combined
return body
def list_queued_issues() -> list[dict]:
@@ -345,12 +294,8 @@ def process_once(*, dry_run: bool = False) -> int:
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
main_sha = get_branch_head(WATCH_BRANCH)
main_status = get_combined_status(main_sha)
# Check push-required contexts explicitly instead of combined state.
# See evaluate_merge_readiness for rationale.
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
if str(main_status.get("state") or "").lower() != "success":
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
return 0
issue = choose_next_queued_issue(
-50
View File
@@ -36,9 +36,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
raw `.error` fields into CI logs/summaries.
9. Production deploy/redeploy workflows must expose an operational control:
kill switch for auto deploys or rollback tag for manual deploys.
10. Docker health checks must not run `docker info | head` under pipefail.
`head` closes the pipe early, `docker info` can exit nonzero from
SIGPIPE, and the step can falsely report Docker daemon failure.
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
@@ -228,24 +225,6 @@ def _iter_uses(doc: Any) -> Iterable[str]:
yield step["uses"]
def _iter_run_blocks(doc: Any) -> Iterable[str]:
"""Yield every shell `run:` block from job steps in a workflow document."""
if not isinstance(doc, dict):
return
jobs = doc.get("jobs")
if not isinstance(jobs, dict):
return
for job in jobs.values():
if not isinstance(job, dict):
continue
steps = job.get("steps")
if not isinstance(steps, list):
continue
for step in steps:
if isinstance(step, dict) and isinstance(step.get("run"), str):
yield step["run"]
def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
"""Return per-violation error lines for cross-repo `uses:` references."""
errors: list[str] = []
@@ -285,10 +264,6 @@ GITHUB_API_REF_RE = re.compile(
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
RUN_SETS_PIPEFAIL_RE = re.compile(r"(?m)^\s*set\s+-[^\n]*o\s+pipefail\b")
DOCKER_INFO_HEAD_PIPE_RE = re.compile(
r"(?m)^\s*docker\s+info\b[^\n|]*\|\s*head\b"
)
RAW_CP_RESPONSE_RE = re.compile(
r"""(?x)
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
@@ -408,30 +383,6 @@ def check_production_operational_control(filename: str, raw: str) -> list[str]:
return errors
# ---------------------------------------------------------------------------
# Rule 10 — docker info piped to head under pipefail
# ---------------------------------------------------------------------------
def check_docker_info_head_pipefail(filename: str, doc: Any) -> list[str]:
errors: list[str] = []
for run_block in _iter_run_blocks(doc):
if not (
RUN_SETS_PIPEFAIL_RE.search(run_block)
and DOCKER_INFO_HEAD_PIPE_RE.search(run_block)
):
continue
errors.append(
f"::error file={filename}::Rule 10 (FATAL): workflow runs "
f"`docker info | head` after enabling `pipefail`. `head` can "
f"close the pipe early, making `docker info` exit nonzero and "
f"falsely fail the Docker daemon health check. Capture "
f"`docker_info=\"$(docker info 2>&1)\"` first, then print a "
f"bounded preview with `printf ... | sed -n '1,5p'`."
)
break
return errors
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
@@ -485,7 +436,6 @@ def main(argv: list[str] | None = None) -> int:
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
fatal_errors.extend(check_production_operational_control(rel, raw))
fatal_errors.extend(check_docker_info_head_pipefail(rel, doc))
warnings.extend(check_github_server_url_missing(rel, doc, raw))
# Cross-file checks
+1 -38
View File
@@ -101,10 +101,9 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
PR_JSON=$(mktemp)
REVIEWS_JSON=$(mktemp)
TEAM_PROBE_TMP=$(mktemp)
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
cleanup() {
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP"
}
trap cleanup EXIT
@@ -144,42 +143,6 @@ if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
exit 1
fi
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
# sop-checklist.py posts `sop-checklist / na-declarations (pull_request)`
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
# the requirement for a Gitea APPROVE review is waived.
NA_STATUSES_TMP=$(mktemp)
HTTP_CODE=$(curl -sS -o "$NA_STATUSES_TMP" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/statuses/${PR_HEAD_SHA}")
debug "statuses/${PR_HEAD_SHA} → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# Gitea returns statuses as array; look for the na-declarations context.
# jq: find all statuses where context == "sop-checklist / na-declarations (pull_request)"
# and state == "success". Extract the description field.
NA_DESC=$(jq -r '
.[] |
select(.context == "sop-checklist / na-declarations (pull_request)") |
select(.state == "success") |
.description
' "$NA_STATUSES_TMP" 2>/dev/null | head -1)
if [ -n "$NA_DESC" ] && [ "$NA_DESC" != "null" ]; then
debug "na-declarations status found: ${NA_DESC}"
# Check if our gate appears in the N/A description.
# The description format is "N/A: qa-review, security-review" or similar.
if echo "$NA_DESC" | grep -iq "\\b${TEAM}-review\\b"; then
echo "::notice::${TEAM}-review N/A — gate declared not-applicable via /sop-n/a: ${NA_DESC}"
echo "::notice::PR ${PR_NUMBER} passes ${TEAM}-review via N/A declaration"
rm -f "$NA_STATUSES_TMP"
exit 0
fi
fi
else
debug "could not fetch statuses (HTTP ${HTTP_CODE}) — proceeding with normal eval"
fi
rm -f "$NA_STATUSES_TMP"
# --- Fetch all reviews on the PR ---
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Re-run review-check.sh for a slash-command refire and post the protected
# pull_request status context to the PR head SHA.
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${TEAM:?TEAM required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
CONTEXT="${TEAM}-review / approved (pull_request)"
TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}"
authfile=$(mktemp)
prfile=$(mktemp)
postfile=$(mktemp)
# shellcheck disable=SC2329 # invoked by EXIT trap
cleanup() {
rm -f "$authfile" "$prfile" "$postfile"
}
trap cleanup EXIT
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
if [ "$code" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}"
head -c 200 "$prfile" >&2 || true
exit 1
fi
head_sha=$(jq -r '.head.sha // ""' "$prfile")
state=$(jq -r '.state // ""' "$prfile")
if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}"
exit 1
fi
if [ "$state" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op"
exit 0
fi
set +e
bash .gitea/scripts/review-check.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
status_state="success"
description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}"
else
status_state="failure"
description="Refired via /${TEAM}-recheck; ${TEAM}-review failed"
fi
body=$(jq -nc \
--arg state "$status_state" \
--arg context "$CONTEXT" \
--arg description "$description" \
--arg target_url "$TARGET_URL" \
'{state:$state, context:$context, description:$description, target_url:$target_url}')
code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \
-K "$authfile" -H "Content-Type: application/json" \
-d "$body" \
"${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
echo "::error::POST /statuses/${head_sha} returned HTTP ${code}"
head -c 200 "$postfile" >&2 || true
exit 1
fi
echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}"
exit "$rc"
+10 -13
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
# sop-checklist — evaluate whether a PR has peer-acked each
# 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.yml on:
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
# - pull_request_target: [opened, edited, synchronize, reopened]
# - issue_comment: [created, edited, deleted]
#
@@ -118,19 +118,17 @@ _DIRECTIVE_RE = re.compile(
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> tuple[list[tuple[str, str, str]], list]:
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns (directives, na_directives) where:
directives is a list of (kind, canonical_slug, note) tuples
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
na_directives is reserved for future N/A handling (always [] for now)
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, []
return out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
@@ -161,7 +159,7 @@ def parse_directives(
# 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, []
return out
# ---------------------------------------------------------------------------
@@ -251,8 +249,7 @@ def compute_ack_state(
user = (c.get("user") or {}).get("login", "")
if not user:
continue
directives, _na = parse_directives(body, numeric_aliases)
for kind, slug, _note in directives:
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
+6 -28
View File
@@ -133,9 +133,6 @@ PUSH_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (workflow has no push: trigger; "
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
)
# Backward-compatible alias for older tests/tooling that predate the split
# between push-suffix compensation and pull-request-shadow compensation.
COMPENSATION_DESCRIPTION = PUSH_COMPENSATION_DESCRIPTION
PR_SHADOW_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (default-branch pull_request status "
"shadowed by successful push status on same SHA; see "
@@ -614,10 +611,11 @@ def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
(verified via vendor-truth probe 2026-05-11 against
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
Raises ApiError on non-2xx OR on unexpected response shape. The
branch-level caller soft-skips this tick because the next scheduled
tick can safely retry the listing. Per-SHA status/write errors remain
separate and must not be mislabeled as commit-list outages.
Raises ApiError on non-2xx OR on unexpected response shape. This is
a HARD halt — without the commit list the sweep can't proceed. (The
per-SHA error isolation downstream is a different concern: tolerating
a transient 5xx on ONE commit's status is best-effort; losing the
commit list itself means we don't even know which commits to try.)
"""
_, body = api(
"GET",
@@ -658,27 +656,7 @@ def reap_branch(
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
SHAs that actually got at least one compensation are included
"""
try:
shas = list_recent_commit_shas(branch, limit)
except ApiError as e:
print(
"::warning::status-reaper skipped this tick because the "
f"commit list could not be read after retries: {e}"
)
return {
"scanned_shas": 0,
"compensated": 0,
"preserved_real_push": 0,
"preserved_unknown": 0,
"preserved_non_failure": 0,
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
"skipped": True,
"skip_reason": "commit-list-api-error",
}
shas = list_recent_commit_shas(branch, limit)
aggregate: dict[str, Any] = {
"scanned_shas": 0,
@@ -85,10 +85,7 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
def test_merge_decision_requires_main_green_pr_green_and_current_base():
required = ["CI / all-required (pull_request)"]
main_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
}
main_status = {"state": "success", "statuses": []}
pr_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
@@ -107,10 +104,7 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base():
def test_merge_decision_updates_stale_pr_before_merge():
decision = mq.evaluate_merge_readiness(
main_status={
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
},
main_status={"state": "success", "statuses": []},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False,
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# Unit tests for sop-checklist.py
# Unit tests for sop-checklist-gate.py
#
# Run: python3 .gitea/scripts/tests/test_sop_checklist.py
# or: pytest .gitea/scripts/tests/test_sop_checklist.py
# Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py
# or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py
#
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
# - slug normalization (the 4 example variants in the script header)
@@ -33,7 +33,7 @@ sys.path.insert(0, PARENT)
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"sop_checklist", os.path.join(PARENT, "sop-checklist.py")
"sop_checklist_gate", os.path.join(PARENT, "sop-checklist-gate.py")
)
sop = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sop) # type: ignore[union-attr]
@@ -134,22 +134,18 @@ class TestParseDirectives(unittest.TestCase):
def setUp(self):
self.aliases = _numeric_aliases()
def parse_ack_revoke(self, body):
directives, na_directives = sop.parse_directives(body, self.aliases)
self.assertEqual(na_directives, [])
return directives
def test_simple_ack(self):
d = self.parse_ack_revoke("/sop-ack comprehensive-testing")
d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases)
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_simple_revoke(self):
d = self.parse_ack_revoke("/sop-revoke staging-smoke")
d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases)
self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
def test_ack_with_note(self):
d = self.parse_ack_revoke(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases"
d = sop.parse_directives(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases",
self.aliases,
)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][0], "sop-ack")
@@ -157,12 +153,13 @@ class TestParseDirectives(unittest.TestCase):
self.assertIn("LGTM", d[0][2])
def test_numeric_shorthand(self):
d = self.parse_ack_revoke("/sop-ack 1")
d = sop.parse_directives("/sop-ack 1", self.aliases)
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_revoke_with_reason(self):
d = self.parse_ack_revoke(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB"
d = sop.parse_directives(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
self.aliases,
)
self.assertEqual(d[0][0], "sop-revoke")
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -174,7 +171,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"Will follow up on the doc nit separately."
)
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -183,7 +180,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"/sop-ack local-postgres-e2e\n"
)
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 2)
slugs = {x[1] for x in d}
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"})
@@ -192,21 +189,21 @@ class TestParseDirectives(unittest.TestCase):
# A directive embedded mid-line is not honored (prevents review
# comments like "to /sop-ack you need..." from acting as acks).
body = "If you want to /sop-ack comprehensive-testing reply in this thread"
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(d, [])
def test_leading_whitespace_allowed(self):
body = " /sop-ack comprehensive-testing"
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 1)
def test_empty_body(self):
self.assertEqual(sop.parse_directives("", self.aliases), ([], []))
self.assertEqual(sop.parse_directives(None, self.aliases), ([], []))
self.assertEqual(sop.parse_directives("", self.aliases), [])
self.assertEqual(sop.parse_directives(None, self.aliases), [])
def test_normalization_applied(self):
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
d = self.parse_ack_revoke("/sop-ack Comprehensive_Testing")
d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases)
self.assertEqual(d[0][1], "comprehensive-testing")
+16 -32
View File
@@ -32,7 +32,6 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0
@@ -88,7 +87,6 @@ assert_file_exists() {
echo
echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then
echo
@@ -106,44 +104,30 @@ echo "== T6/T7 workflow yaml =="
PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$WORKFLOW" 2>&1 || true)
assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT"
# The old per-workflow issue_comment listener caused queue storms because
# Gitea queues jobs before evaluating job-level `if:`. The script remains,
# but comment-triggered refires route through the single dispatcher.
# Three required gates in the `if:` expression
WORKFLOW_CONTENT=$(cat "$WORKFLOW")
if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then
echo " FAIL T6a manual fallback workflow must not listen on issue_comment"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6a"
else
echo " PASS T6a manual fallback workflow does not listen on issue_comment"
PASS=$((PASS + 1))
fi
assert_contains "T6b workflow exposes workflow_dispatch" \
"workflow_dispatch" "$WORKFLOW_CONTENT"
assert_contains "T6c workflow documents unsupported manual inputs" \
"workflow_dispatch inputs" "$WORKFLOW_CONTENT"
assert_contains "T6a workflow if: contains author_association gate" \
"github.event.comment.author_association" "$WORKFLOW_CONTENT"
assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \
'["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT"
assert_contains "T6c workflow if: contains slash-command trigger" \
"/refire-tier-check" "$WORKFLOW_CONTENT"
assert_contains "T6d workflow if: gates on PR-not-issue" \
"github.event.issue.pull_request" "$WORKFLOW_CONTENT"
assert_contains "T6e workflow listens on issue_comment" \
"issue_comment" "$WORKFLOW_CONTENT"
assert_contains "T6f workflow requests statuses:write permission" \
"statuses: write" "$WORKFLOW_CONTENT"
# Does NOT check out PR HEAD (security)
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
echo " FAIL T6d workflow MUST NOT check out PR head (security)"
echo " FAIL T6g workflow MUST NOT check out PR head (security)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6d"
FAILED_TESTS="${FAILED_TESTS} T6g"
else
echo " PASS T6d workflow does not check out PR head"
echo " PASS T6g workflow does not check out PR head"
PASS=$((PASS + 1))
fi
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
assert_contains "T6f dispatcher listens on issue_comment" \
"issue_comment" "$DISPATCH_CONTENT"
assert_contains "T6g dispatcher handles /qa-recheck" \
"/qa-recheck" "$DISPATCH_CONTENT"
assert_contains "T6h dispatcher handles /security-recheck" \
"/security-recheck" "$DISPATCH_CONTENT"
assert_contains "T6i dispatcher handles /refire-tier-check" \
"/refire-tier-check" "$DISPATCH_CONTENT"
# T1-T5 — script behavior against a local Gitea-fixture
echo
echo "== T1-T5 script behavior (vs local fixture) =="
-36
View File
@@ -107,39 +107,3 @@ items:
description: >-
List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access.
# N/A gate declarations (RFC#324 §N/A follow-up).
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
# qa surface, or docs-only) can be declared N/A by a non-author peer
# who is in one of the gate's required_teams. The sop-checklist
# posts a `sop-checklist / na-declarations (pull_request)` status that
# review-check.sh reads to skip the Gitea-APPROVE requirement.
#
# Usage: any PR commenter (peer) posts:
# /sop-n/a qa-review <reason>
# /sop-n/a security-review <reason>
#
# Slash commands:
# /sop-n/a <gate> [reason] — declare gate N/A (most-recent per-user wins)
# /sop-revoke <gate> — revoke prior N/A declaration for that gate
#
# Gate names must match the context strings used by review-check.sh:
# qa-review → qa-review / approved (<event>) [TEAM_ID=20]
# security-review → security-review / approved (<event>) [TEAM_ID=21]
#
# required_teams: OR semantics — any team member can declare N/A.
# Authors cannot self-declare N/A (enforced by gate script).
n/a_gates:
qa-review:
required_teams: [qa, security, engineers]
description: >-
QA review N/A when this change has no qa surface (pure-infra,
tooling-only, revert, dependency-only). A qa/eng/security member
must post /sop-n/a qa-review to activate.
security-review:
required_teams: [security, managers, ceo]
description: >-
Security review N/A when this change has no security surface
(docs-only, pure-frontend, dependency-only). A security/owners
member must post /sop-n/a security-review to activate.
+66 -104
View File
@@ -107,25 +107,16 @@ jobs:
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Workflow-only edits are covered by the workflow lint family
# and by this workflow's always-present required jobs. Do not fan
# those edits out into Go/Canvas/Python/shellcheck work; the
# downstream jobs still emit their required contexts via no-op
# steps when their surface flag is false.
#
# If the diff itself cannot be trusted, fail open by running every
# surface instead of silently under-testing the PR.
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# 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
@@ -133,49 +124,59 @@ jobs:
# 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 (closed 2026-05-14): Phase 4 flip of the platform-build job.
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
# Phase-3-masked "green on main 2026-05-12". Two failure classes then surfaced:
# (1) 4x delegation_test.go sqlmock gaps (PR #669 / #634 fix-forward, closed).
# (2) TestMCPHandler_CommitMemory_GlobalScope_Blocked (mcp_test.go:433):
# OFFSEC-001 hardening collided with test assertion; tracked in mc#762.
# Fix-forward for (1) landed in PR #669. The mc#762 gap (2) is a separate
# issue — it does NOT block this flip because the test is already wrapped in
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
# this cap catches any step that leaks past that. Set well above 10m so
# the per-step timeout is the active constraint.
timeout-minutes: 15
# 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: false
- 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: always()
- if: needs.changes.outputs.platform == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: always()
- if: needs.changes.outputs.platform == 'true'
run: go mod download
- if: always()
- 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: always()
- if: needs.changes.outputs.platform == 'true'
run: go vet ./...
- if: always()
- 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: always()
- if: needs.changes.outputs.platform == 'true'
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -191,15 +192,11 @@ jobs:
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
run: go test -race -coverprofile=coverage.out ./...
- if: always()
- 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
@@ -213,7 +210,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: always()
- 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).
@@ -301,28 +298,28 @@ jobs:
# siblings — verified empirically on PR #2314).
canvas-build:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
defaults:
run:
working-directory: canvas
steps:
- if: false
- 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: always()
- if: needs.changes.outputs.canvas == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: always()
- if: needs.changes.outputs.canvas == 'true'
run: rm -f package-lock.json && npm install
- if: always()
- if: needs.changes.outputs.canvas == 'true'
run: npm run build
- if: always()
- 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
@@ -331,7 +328,7 @@ jobs:
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: always()
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
@@ -377,57 +374,23 @@ jobs:
run: |
bash tests/e2e/test_model_slug.sh
- if: needs.changes.outputs.scripts == 'true'
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
# closing molecule-ai/molecule-core#660. 40 mock-driven cases
# exercise every exit path (preflight, snapshot, promote, redeploy
# 403→SSM-refresh, verify, rollback). No live AWS/CP/SSM calls.
run: |
bash scripts/test-promote-tenant-image.sh
- if: needs.changes.outputs.scripts == 'true'
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
# the promote script + its test harness so regressions there are
# caught by the required check.
run: |
shellcheck --severity=warning \
scripts/promote-tenant-image.sh \
scripts/test-promote-tenant-image.sh
# mc#959 root-fix (sre)
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
# ci_job_names() detects this as github.ref-gated and skips it from F1.
# The step-level exit 0 handles the "not main push" case; the job-level
# `if:` makes the gating explicit so the drift script sees it.
# continue-on-error removed (was mc#774 mask): step exits 0 when not applicable.
if: ${{ github.ref == 'refs/heads/staging' }}
# 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 }}
CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref }}
# 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: |
set -euo pipefail
if [ "$CANVAS_CHANGED" != "true" ] || [ "$EVENT_NAME" != "push" ] || [ "$REF_NAME" != "refs/heads/main" ]; then
echo "Canvas deploy reminder not applicable for event=$EVENT_NAME ref=$REF_NAME canvas_changed=$CANVAS_CHANGED."
exit 0
fi
# Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed — deploy required
@@ -572,11 +535,11 @@ jobs:
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
# Including it in all-required.needs causes all-required to hang on
# every CI-only PR. Keep it runnable on PRs via its own
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
# 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)
@@ -596,8 +559,7 @@ jobs:
- canvas-build
- shellcheck
- python-lint
- canvas-deploy-reminder
if: ${{ always() }}
if: always()
steps:
- name: Assert every required dependency succeeded
run: |
-5
View File
@@ -48,9 +48,4 @@ jobs:
REQUIRED_CONTEXTS: >-
CI / all-required (pull_request),
sop-checklist / all-items-acked (pull_request)
# Push-side required contexts. Checking CI / all-required (push)
# explicitly instead of the combined state avoids false-pause when
# non-blocking jobs (continue-on-error: true) have failed — those
# failures pollute combined state but do not gate merges.
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
run: python3 .gitea/scripts/gitea-merge-queue.py
@@ -90,25 +90,18 @@ jobs:
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
# Gitea Actions evaluates github.event.before to empty string in shell
# scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea
# correctly populates it for push events). PR case uses template var.
BASE=""
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# timeout 30 guards against the case where BASE points to a ref that
# git can resolve but cat-file hangs (rare on corrupted objects).
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -37,6 +37,12 @@ name: publish-workspace-server-image
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
@@ -68,14 +74,12 @@ jobs:
set -euo pipefail
echo "::group::Docker daemon health check"
echo "Runner: ${HOSTNAME:-unknown}"
docker_info="$(docker info 2>&1)" || {
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Runner: ${HOSTNAME:-unknown}"
printf '%s\n' "${docker_info}"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
printf '%s\n' "${docker_info}" | sed -n '1,5p'
echo "Docker daemon OK"
echo "::endgroup::"
+17 -9
View File
@@ -9,10 +9,10 @@
# Triggers on:
# - `pull_request_target`: opened, synchronize, reopened
# → initial status posts when PR opens / re-pushes
# - comment refires are handled by `review-refire-comments.yml`
# → a single issue_comment dispatcher prevents every SOP/review
# comment from enqueueing separate qa/security/tier jobs on
# Gitea 1.22.6 before job-level `if:` can skip them.
# - `issue_comment`: /qa-recheck slash-command on the PR
# → manual re-fire after a QA reviewer clicks APPROVE
# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
# Workflow name = `qa-review` ; job name = `approved`.
# The job's own pass/fail conclusion publishes the status context
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
@@ -85,6 +85,8 @@ name: qa-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -95,10 +97,16 @@ jobs:
approved:
# Gate the job:
# - On pull_request_target events: always run.
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
# - On issue_comment events: only when it's a PR comment and the body
# contains the slash-command. NO privilege gate at the step level
# (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine
# because the eval is read-only and idempotent — re-running it
# just re-confirms whether a real team-member APPROVE exists.
if: |
github.event_name == 'pull_request_target'
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/qa-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -112,7 +120,7 @@ jobs:
# no comment.user.login so the step is a no-op skip there.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -143,7 +151,7 @@ jobs:
- name: Evaluate qa-review
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
# PR number lives in different places per event:
+65 -54
View File
@@ -9,17 +9,19 @@ name: redeploy-tenants-on-main
# - Workflow-level env.GITHUB_SERVER_URL pinned per
# feedback_act_runner_github_server_url.
# - `continue-on-error: true` on each job (RFC §1 contract).
# - Dropped unsupported `workflow_run` (task #81).
# - Later changed to manual-only after publish-workspace-server-image.yml
# gained an integrated ordered production deploy job.
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
# push+paths filter per this PR. Gitea 1.22.6 does not support
# `workflow_run` (task #81). The push trigger fires on every
# commit to publish-workspace-server-image.yml which is the
# same signal (only successful runs commit to main).
#
# Manual production tenant redeploy/rollback helper.
# Auto-refresh prod tenant EC2s after every main merge.
#
# Why this workflow is manual-only: publish-workspace-server-image now owns
# the ordered build -> push -> production auto-deploy sequence in one workflow.
# A separate push-triggered redeploy workflow races before the new ECR image
# exists and can paint main red with a false deployment failure.
# Why this workflow exists: publish-workspace-server-image builds and
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
# but running tenants pulled their image once at boot and never re-pull.
# Users see stale code indefinitely.
#
# This workflow closes the gap by calling the control-plane admin
# endpoint that performs a canary-first, batched, health-gated rolling
@@ -32,26 +34,36 @@ name: redeploy-tenants-on-main
# Gitea suspension migration. The staging-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
#
# Runtime ordering for automatic deploys now lives in
# publish-workspace-server-image.yml:
# 1. build-and-push creates new :staging-<sha> images in ECR.
# 2. deploy-production waits for required push contexts on that SHA.
# 3. deploy-production calls redeploy-fleet canary-first.
# Runtime ordering:
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
# 2. This workflow fires via workflow_run, calls redeploy-fleet with
# target_tag=staging-<sha>. No CDN propagation wait needed —
# ECR image manifest is consistent immediately after push.
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
# period. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
#
# Rollback path: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
# variable or secret, run workflow_dispatch, then unset it after the
# rollback. That calls redeploy-fleet with target_tag=<value>,
# re-pulling the pinned image on every tenant.
# Rollback path: re-run this workflow with a specific SHA pinned via
# the workflow_dispatch input. That calls redeploy-fleet with
# target_tag=<sha>, re-pulling the older image on every tenant.
on:
push:
branches: [main]
paths:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
permissions:
contents: read
# No write scopes needed — the workflow hits an external CP endpoint,
# not the GitHub API.
# Serialize manual redeploys so two operator-triggered rollbacks do not
# overlap and cause confusing per-tenant SSM state.
# Serialize redeploys so two rapid main pushes' redeploys don't overlap
# and cause confusing per-tenant SSM state. Without this, GitHub's
# implicit workflow_run queueing would *probably* serialize them, but
# the explicit block makes the invariant defensible. Mirrors the
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
#
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
# cancels queued runs regardless of this setting, so it provides no
@@ -65,17 +77,22 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
redeploy:
if: ${{ github.event_name == 'workflow_dispatch' }}
# Skip the auto-trigger if publish-workspace-server-image didn't
# actually succeed. workflow_run fires on any completion state; we
# don't want to redeploy against a half-built image.
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
# workflow_run path remains.
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 25
env:
# Rule 9 fix: keep the same operational kill switch surface as the
# integrated auto-deploy workflow.
# Rule 9 fix: operational kill switch for auto-triggered deployments.
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
steps:
- name: Kill-switch guard
@@ -97,16 +114,21 @@ jobs:
# tag) → used verbatim. Lets ops pin `latest` for emergency
# rollback to last canary-verified digest, or pin a specific
# `staging-<sha>` to roll back to a known-good build.
# 2. Default → `staging-<short_head_sha>` for manual reruns from
# the current default-branch SHA.
# 2. Default → `staging-<short_head_sha>`. The just-published
# digest. Bypasses the `:latest` retag path that's currently
# dead (staging-verify soft-skips without canary fleet, so
# the only thing retagging `:latest` today is the manual
# promote-latest.yml — last run 2026-04-28). Auto-trigger
# from workflow_run uses workflow_run.head_sha; manual
# dispatch with no input falls through to github.sha.
env:
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
HEAD_SHA: ${{ github.sha }}
INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -euo pipefail
if [ -n "${PROD_MANUAL_REDEPLOY_TARGET_TAG:-}" ]; then
echo "target_tag=$PROD_MANUAL_REDEPLOY_TARGET_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag from PROD_MANUAL_REDEPLOY_TARGET_TAG."
if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag: $INPUT_TAG"
else
SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
@@ -122,26 +144,13 @@ jobs:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ vars.PROD_REDEPLOY_CANARY_SLUG || secrets.PROD_REDEPLOY_CANARY_SLUG || '' }}
SOAK_SECONDS: ${{ vars.PROD_REDEPLOY_SOAK_SECONDS || secrets.PROD_REDEPLOY_SOAK_SECONDS || '' }}
BATCH_SIZE: ${{ vars.PROD_REDEPLOY_BATCH_SIZE || secrets.PROD_REDEPLOY_BATCH_SIZE || '' }}
DRY_RUN: ${{ vars.PROD_REDEPLOY_DRY_RUN || secrets.PROD_REDEPLOY_DRY_RUN || '' }}
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: |
set -euo pipefail
case "${PROD_AUTO_DEPLOY_DISABLED,,}" in
1|true|yes|on)
echo "::notice::PROD_AUTO_DEPLOY_DISABLED is set; skipping production redeploy."
exit 0
;;
esac
CANARY_SLUG="${CANARY_SLUG:-hongming}"
SOAK_SECONDS="${SOAK_SECONDS:-60}"
BATCH_SIZE="${BATCH_SIZE:-3}"
DRY_RUN="${DRY_RUN:-false}"
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
@@ -163,7 +172,7 @@ jobs:
}')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag=$TARGET_TAG canary=$CANARY_SLUG soak_seconds=$SOAK_SECONDS batch_size=$BATCH_SIZE dry_run=$DRY_RUN"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
@@ -252,11 +261,13 @@ jobs:
# fail the workflow, which is what `ok=true` should have
# guaranteed all along.
#
# When the redeploy is triggered manually with a specific tag
# (target_tag != "latest"), the expected SHA may not equal
# ${{ github.sha }}.
# When the redeploy was triggered by workflow_dispatch with a
# specific tag (target_tag != "latest"), the expected SHA may
# not equal ${{ github.sha }} — in that case we resolve via
# GHCR's manifest. For workflow_run (default :latest) the
# workflow_run.head_sha is the SHA that just published.
env:
EXPECTED_SHA: ${{ github.sha }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
# Tenant subdomain template — slugs from the response are
# appended. Production CP issues `<slug>.moleculesai.app`;
@@ -270,10 +281,10 @@ jobs:
if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
# Manual redeploy with a pinned tag that isn't the head
# workflow_dispatch with a pinned tag that isn't the head
# SHA — operator is rolling back / pinning. Skip the
# verification because we don't have the expected SHA in
# this context (would need to inspect the ECR
# this context (would need to crane-inspect the GHCR
# manifest, which is a follow-up). Failing-open here is
# safe: the operator chose the tag deliberately.
#
-109
View File
@@ -1,109 +0,0 @@
# Consolidated comment dispatcher for manual review/tier refires.
#
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
# qa-review, security-review, sop-checklist, and sop-tier-refire all
# listened to comments. This workflow is the single non-SOP comment subscriber:
# ordinary comments no-op quickly; slash commands post the required status
# contexts to the PR head SHA.
name: review-refire-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
if [ "$IS_PR" != "true" ]; then
echo "::notice::not a PR comment; no-op"
exit 0
fi
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
+4 -13
View File
@@ -66,28 +66,19 @@ jobs:
# PR#372's ci.yml port used. Diffs against the PR base or the
# previous push SHA, then matches against the wheel-relevant
# path set.
#
# NOTE: Gitea Actions does not expose github.event.before as a
# shell environment variable. The ${{ github.event.before }} template
# expression works inside YAML run: blocks but is evaluated to an
# empty string for push events, making the ${VAR:-fallback} always
# use the fallback. Use GITHUB_EVENT_BEFORE instead — it IS set in
# the runner's shell environment for push events.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch or no previous SHA: treat as wheel-relevant.
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
+10 -5
View File
@@ -12,6 +12,8 @@ name: security-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -20,10 +22,13 @@ permissions:
jobs:
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
approved:
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
# log only, NOT a gate) / A4 / A5 design rationale.
if: |
github.event_name == 'pull_request_target'
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/security-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -32,7 +37,7 @@ jobs:
# so re-running on a non-collaborator comment is harmless.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -57,7 +62,7 @@ jobs:
- name: Evaluate security-review
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
@@ -1,4 +1,4 @@
# sop-checklist — peer-ack merge gate for SOP-checklist items.
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
@@ -65,15 +65,7 @@
# 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
# Cancel any in-progress runs for the same PR to prevent
# stale runs from overwriting newer status contexts.
concurrency:
group: ${{ github.repository }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
name: sop-checklist-gate
on:
pull_request_target:
@@ -91,7 +83,7 @@ permissions:
statuses: write
jobs:
all-items-acked:
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.
@@ -100,8 +92,7 @@ jobs:
(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') ||
contains(github.event.comment.body, '/sop-n/a')))
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
@@ -114,7 +105,7 @@ jobs:
# qa-review.yml so the script source is always trusted.
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist
- 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 }}
@@ -122,7 +113,7 @@ jobs:
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist.py \
python3 .gitea/scripts/sop-checklist-gate.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
+40 -13
View File
@@ -1,4 +1,4 @@
# sop-tier-refire — manual fallback for sop-tier-check refire.
# sop-tier-refire — issue_comment-triggered refire of sop-tier-check.
#
# Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the
# `pull_request_review` event (go-gitea/gitea#33700); the `sop-tier-check`
@@ -8,12 +8,12 @@
# to merge is the admin force-merge path (audited via `audit-force-merge`
# but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`).
#
# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea
# queues issue_comment workflows before evaluating job-level `if:`, so having
# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe
# to every comment caused queue storms on SOP-heavy PRs. This workflow is a
# non-automatic breadcrumb only; Gitea 1.22.6 does not support
# workflow_dispatch inputs, so real refires must use `/refire-tier-check`.
# Workaround pattern from `feedback_pull_request_review_no_refire`:
# `issue_comment` events DO fire reliably on 1.22.6. When a repo
# MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this
# workflow re-runs the sop-tier-check logic and POSTs the resulting
# status to the PR head SHA directly. No empty commit, no git history
# bloat, no cascade re-fire of every other workflow on the PR.
#
# SECURITY MODEL:
#
@@ -37,16 +37,43 @@
# Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s"
# guard prevents comment-spam from thrashing the status. See the script.
name: sop-tier-check refire (manual)
name: sop-tier-check refire (issue_comment)
on:
workflow_dispatch:
issue_comment:
types: [created]
jobs:
refire:
# Three gates, all required:
# - comment is on a PR (not a plain issue)
# - commenter is MEMBER, OWNER, or COLLABORATOR
# - comment body contains the slash-command trigger
if: |
github.event.issue.pull_request != null &&
contains(fromJson('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) &&
contains(github.event.comment.body, '/refire-tier-check')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: write
steps:
- name: Explain supported refire path
run: |
echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead."
exit 1
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Load the script from the default branch (main), matching the
# sop-tier-check.yml security model.
ref: ${{ github.event.repository.default_branch }}
- name: Re-evaluate sop-tier-check and POST status
env:
# Same org-level secret sop-tier-check.yml + audit-force-merge.yml use.
# Fallback to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default.
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
+2 -2
View File
@@ -327,7 +327,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={href}
className="rounded bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600"
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Open
</a>
@@ -337,7 +337,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={`/pricing?org=${encodeURIComponent(org.slug)}`}
className="rounded bg-amber-800 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
className="rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500"
>
Complete payment
</a>
+6 -14
View File
@@ -8,17 +8,11 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
type EventFilter = "all" | AuditEntry["event_type"];
// Contrast note: text is rendered on near-black bg (bg-*-950/40). Every text
// color below is chosen to pass WCAG 2.1 AA 4.5:1 on that background:
// blue-300 ( delegation ) ≈ 8.8:1
// violet-300 ( decision ) ≈ 9.5:1
// yellow-200 ( gate ) ≈ 11.5:1
// orange-300 ( hitl ) ≈ 9.1:1
const BADGE_COLORS: Record<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
delegation: { text: "text-blue-300", bg: "bg-blue-950/40", border: "border-blue-800/40" },
decision: { text: "text-violet-300", bg: "bg-violet-950/40", border: "border-violet-800/40" },
gate: { text: "text-yellow-200", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
hitl: { text: "text-orange-300", bg: "bg-orange-950/40", border: "border-orange-800/40" },
delegation: { text: "text-accent", bg: "bg-blue-950/40", border: "border-blue-800/40" },
decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" },
gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
};
const FILTERS: { id: EventFilter; label: string }[] = [
@@ -170,10 +164,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{/* Error banner */}
{error && (
<div
role="alert"
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
>
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
{error}
</div>
)}
@@ -251,6 +242,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
{/* Event-type badge */}
<span
className={`shrink-0 text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${badge.text} ${badge.bg} ${badge.border}`}
aria-label={`Event type: ${entry.event_type}`}
>
{entry.event_type}
</span>
+5 -5
View File
@@ -100,8 +100,8 @@ export function BatchActionBar() {
aria-label="Batch workspace actions"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
>
{/* Selection count badge — bg-zinc-700 passes 7.2:1 on white text */}
<span className="text-[12px] font-semibold text-white bg-zinc-700 px-2.5 py-0.5 rounded-full tabular-nums">
{/* Selection count badge */}
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected
</span>
@@ -112,7 +112,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("restart")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
>
<span aria-hidden="true"></span>
Restart All
@@ -122,7 +122,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
>
<span aria-hidden="true"></span>
Pause All
@@ -132,7 +132,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
>
<span aria-hidden="true"></span>
Delete All
+1 -1
View File
@@ -96,7 +96,7 @@ export function ConfirmDialog({
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-700 hover:bg-red-600 text-white"
? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning"
? "bg-amber-800 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
+1 -1
View File
@@ -318,7 +318,7 @@ export function ContextMenu() {
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/>
<span className="text-[10px] text-ink">{contextMenu.nodeData.status}</span>
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span>
</div>
</div>
@@ -187,7 +187,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
isError
? "bg-red-950/50 text-bad"
: isSend
? "bg-cyan-950 text-cyan-300"
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-accent"
: "bg-surface-card text-ink-mid"
@@ -251,7 +251,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-bad mt-1 truncate">
<div className="text-[10px] text-bad/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
@@ -272,7 +272,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
)}
{responseText && (
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-good uppercase mb-1">Response</div>
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
@@ -126,8 +126,8 @@ export function DeleteCascadeConfirmDialog({
{/* Cascade warning */}
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
<p className="text-[12px] text-red-300 leading-relaxed">
Deleting will cascade <strong className="text-red-100">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
<p className="text-[12px] text-bad/80 leading-relaxed">
Deleting will cascade <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
</p>
</div>
@@ -164,13 +164,13 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
// Hover goes DARKER, not lighter — bg-red-600 on white text
// drops contrast below AA. Same trap fixed in ConfirmDialog.
// focus-visible ring matches the canvas chrome.
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-700 hover:bg-red-600 text-white cursor-pointer"
: "bg-red-900/30 text-red-400 cursor-not-allowed"
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>
Delete All
+2 -2
View File
@@ -51,7 +51,7 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<div role="alert" aria-live="assertive" className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
<svg
@@ -76,7 +76,7 @@ export class ErrorBoundary extends React.Component<
<p className="text-sm text-ink-mid mb-1">
An unexpected error occurred while rendering the application.
</p>
<p className="text-xs text-bad mb-6 font-mono break-all">
<p className="text-xs text-bad/80 mb-6 font-mono break-all">
{this.state.error?.message ?? "Unknown error"}
</p>
<div className="flex items-center justify-center gap-3">
@@ -360,7 +360,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent text-white hover:bg-accent-strong transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{copied ? "Copied!" : "Copy"}
</button>
+4 -4
View File
@@ -344,7 +344,7 @@ function ProviderPickerModal({
// wrapper's bounds instead of the viewport.
if (typeof document === "undefined") return null;
const allSaved = entries.every((e) => e.saved);
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -451,7 +451,7 @@ function ProviderPickerModal({
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -492,7 +492,7 @@ function ProviderPickerModal({
!selectorValue.providerId ||
(showModelInput && model.trim() === "")
}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
@@ -616,7 +616,7 @@ function AllKeysModal({
if (!open) return null;
if (typeof document === "undefined") return null;
const allSaved = entries.every((e) => e.saved);
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -420,7 +420,7 @@ export function ProviderModelSelector({
spellCheck={false}
autoComplete="off"
data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:border-accent transition-colors disabled:opacity-50"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/>
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
{selected?.wildcard
@@ -389,7 +389,7 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-800 hover:bg-red-700 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
>
Remove Workspace
</button>
+2 -6
View File
@@ -61,12 +61,8 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
return;
}
setTheme(OPTIONS[next].value);
// Move focus to the new button so arrow-key navigation is continuous.
// Query is already scoped to radiogroup so no child-combinator needed;
// avoids accidentally focusing unrelated [role=radio] elements
// elsewhere in the DOM (e.g. React Flow canvas nodes).
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("[role=radio]");
// Move focus to the new button so arrow-key navigation is continuous
const btns = (e.currentTarget.closest("[role=radiogroup]") as HTMLElement)?.querySelectorAll<HTMLButtonElement>("[role=radio]");
btns?.[next]?.focus();
},
[]
+7 -10
View File
@@ -13,20 +13,17 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
/** Descendant count for the "N sub" badge — children are first-class nodes
* rendered as full cards inside this one via React Flow's native parentId,
* so we don't need to subscribe to the actual child list here.
* Selecting `nodes` stably avoids a new selector reference on every store
* update (React error #185 / Zustand + React 19 Object.is strictness). */
* so we don't need to subscribe to the actual child list here. */
function useDescendantCount(nodeId: string): number {
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
return useCanvasStore(
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
);
}
/** Boolean flag used to drive min-size and NodeResizer dimensions.
* Selecting `nodes` stably avoids re-render loops (same issue as
* useDescendantCount). */
function useHasChildren(nodeId: string): boolean {
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
return useCanvasStore(
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
);
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
@@ -24,20 +24,16 @@ import {
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
// Select nodes stably first — deriving targetName and childCount inside
// the same selector creates a new return value on every store mutation
// even when neither has changed (React error #185 / Zustand Object.is).
const nodes = useCanvasStore((s) => s.nodes);
const targetName = (() => {
if (!dragOverNodeId) return null;
const n = nodes.find((nn) => nn.id === dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
})();
const childCount = (() =>
!dragOverNodeId
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: nodes.filter((n) => n.parentId === dragOverNodeId).length
)();
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
@@ -68,7 +64,6 @@ export function DropTargetBadge() {
{ghostVisible && (
<div
data-testid="ghost-slot"
aria-hidden="true"
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
@@ -80,9 +75,7 @@ export function DropTargetBadge() {
)}
<div
data-testid="drop-badge"
role="status"
aria-label={`Drop target: ${targetName}`}
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-700 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
@@ -1,311 +0,0 @@
/**
* Unit tests for buildDeployMap — the pure tree-traversal core of
* useOrgDeployState.
*
* What is tested here:
* - Root / leaf identification via parent-chain walk
* - isDeployingRoot: true when any descendant is "provisioning"
* - isActivelyProvisioning: true only for the node itself in that state
* - isLockedChild: true for non-root nodes in a deploying tree
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
* - descendantProvisioningCount: non-zero only on root nodes
* - Performance contract: O(n) single-pass walk — tested by verifying
* correctness across 50-node trees (n=50, all cases above)
*
* What is NOT tested here (hook integration — appropriate for E2E):
* - The useMemo / Zustand subscription wiring
* - React Flow integration (flowToScreenPosition, getInternalNode)
*
* Issue: #2071 (Canvas test gaps follow-up).
*/
import { describe, expect, it } from "vitest";
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
// ── Helpers ──────────────────────────────────────────────────────────────────
type Projection = { id: string; parentId: string | null; status: string };
function proj(
id: string,
parentId: string | null,
status: string,
): Projection {
return { id, parentId, status };
}
/** Unchecked cast — test helpers aren't production code paths. */
function m(
ps: Projection[],
deletingIds: string[] = [],
): Map<string, OrgDeployState> {
return buildDeployMap(ps, new Set(deletingIds));
}
function s(
map: Map<string, OrgDeployState>,
id: string,
): OrgDeployState {
const got = map.get(id);
if (!got) throw new Error(`no entry for id=${id}`);
return got;
}
// ── Empty / trivial ───────────────────────────────────────────────────────────
describe("buildDeployMap — empty", () => {
it("returns empty map for empty projections", () => {
expect(m([]).size).toBe(0);
});
});
// ── Single node ─────────────────────────────────────────────────────────────
describe("buildDeployMap — single node", () => {
it("isolated node is its own root and not deploying", () => {
const map = m([proj("a", null, "online")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
});
});
it("isolated provisioning node is deploying root", () => {
const map = m([proj("a", null, "provisioning")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: true,
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
});
});
// ── Parent / child chains ─────────────────────────────────────────────────────
describe("buildDeployMap — parent / child chains", () => {
it("root with online child: root is not deploying, child is not locked", () => {
// A ──► B
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
it("root with provisioning child: root is deploying, child is locked", () => {
// A ──► B (B is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
});
it("provisioning root with online child: root is deploying, child is locked", () => {
// A (provisioning) ──► B (online)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("grandchild inherits deploy lock through intermediate online node", () => {
// A ──► B ──► C (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
]);
// B and C are both non-root descendants of the deploying root
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
});
it("deep chain: only the topmost node with a null parent counts as root", () => {
// A ──► B ──► C ──► D (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
proj("D", "C", "online"),
]);
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
expect(roots).toEqual(["A"]);
});
});
// ── Sibling branching ─────────────────────────────────────────────────────────
describe("buildDeployMap — sibling branching", () => {
it("parent with multiple children: deploying root propagates to all children", () => {
// A (provisioning)
// / \
// B C
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "A", "online"),
]);
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
});
it("only one provisioning descendant marks the root as deploying", () => {
// A
// / | \
// B C D (only C is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "A", "provisioning"),
proj("D", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
});
it("two provisioning siblings: count reflects both", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
proj("C", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
});
});
// ── Multiple disjoint trees ───────────────────────────────────────────────────
describe("buildDeployMap — multiple disjoint trees", () => {
it("each tree has its own root; deploying nodes are independent", () => {
// Tree 1: X (provisioning) ──► Y
// Tree 2: P ──► Q (no provisioning)
const map = m([
proj("X", null, "provisioning"),
proj("Y", "X", "online"),
proj("P", null, "online"),
proj("Q", "P", "online"),
]);
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
});
// ── Deleting nodes ────────────────────────────────────────────────────────────
describe("buildDeployMap — deletingIds", () => {
it("node in deletingIds is locked even if tree is not deploying", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
["B"], // B is being deleted
);
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
const map = m(
[
proj("A", null, "provisioning"),
proj("B", "A", "online"),
],
["B"],
);
// B is both a deploying-child AND a deleting node — either alone locks it
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
});
it("empty deletingIds set has no effect", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
[],
);
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
});
});
// ── descendantProvisioningCount ───────────────────────────────────────────────
describe("buildDeployMap — descendantProvisioningCount", () => {
it("is 0 for non-root nodes", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "B").descendantProvisioningCount).toBe(0);
});
it("includes the root's own status when provisioning", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
// A is both root and provisioning → count includes itself
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
it("accumulates all provisioning descendants (not just immediate children)", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "B", "provisioning"),
]);
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
});
// ── O(n) performance ─────────────────────────────────────────────────────────
describe("buildDeployMap — O(n) performance contract", () => {
it("handles a 50-node three-level tree without incorrect node assignments", () => {
// Level 0: 1 root
// Level 1: 7 children
// Level 2: 42 leaves
// Total: 50 nodes
const projections: Projection[] = [];
projections.push(proj("root", null, "provisioning"));
for (let i = 0; i < 7; i++) {
projections.push(proj(`l1-${i}`, "root", "online"));
}
for (let i = 0; i < 42; i++) {
const parent = `l1-${Math.floor(i / 6)}`;
projections.push(proj(`l2-${i}`, parent, "online"));
}
const map = m(projections);
// Root is the only deploying node
expect(s(map, "root")).toMatchObject({
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
// Every other node is a locked child
for (let i = 0; i < 7; i++) {
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
for (let i = 0; i < 42; i++) {
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { appendClass, removeClass } from "@/store/classNames";
@@ -153,17 +153,10 @@ export function useCanvasViewport() {
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
//
// Selecting `nodes` stably (array reference) avoids the
// `.filter().length` anti-pattern which creates a new number on every
// store update and breaks the wasProvisioning/hasProvisioning
// transition detection (React error #185 / Zustand + React 19).
const nodes = useCanvasStore((s) => s.nodes);
const provisioningCount = useMemo(
() => nodes.filter((n) => n.data.status === "provisioning").length,
[nodes],
const provisioningCount = useCanvasStore(
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
);
const nodeCount = nodes.length;
const nodeCount = useCanvasStore((s) => s.nodes.length);
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
+11 -150
View File
@@ -5,7 +5,7 @@
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -36,20 +36,6 @@ interface A2AResponseShape {
error?: { message?: string };
}
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
interface ApiChatMessage {
id: string;
role: string; // "user" | "agent" | "system"
content: string;
timestamp: string;
attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
}
interface ChatHistoryResponse {
messages: ApiChatMessage[];
reached_end: boolean;
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -63,10 +49,7 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
@@ -75,14 +58,18 @@ export function MobileChat({
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
// Start empty — history is loaded via useEffect below.
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
(storedMessages ?? []).map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // history is loading on mount
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
@@ -90,9 +77,6 @@ export function MobileChat({
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Guard: don't treat the initial store population as a live push.
// Set to false after the first render completes.
const initDoneRef = useRef(false);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -105,75 +89,6 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
// Fetch chat history on mount; keep merging live agentMessages while the
// panel is open. InitDoneRef prevents the initial store snapshot from
// triggering the live-merge path (the store buffer is populated by
// ChatTab on desktop, not on mobile — this effect loads history as the
// mobile-native path).
useEffect(() => {
let cancelled = false;
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
});
const syncLive = () => {
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
if (live.length > 0) {
setMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const newOnes = live
.filter((m) => !existingIds.has(m.id))
.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
});
}
};
const bootstrap = async (): Promise<(() => void) | undefined> => {
setLoading(true);
setHistoryError(null);
try {
const res = await api.get<ChatHistoryResponse>(
`/workspaces/${agentId}/chat-history?limit=50`,
);
if (cancelled) return;
const initial = (res.messages ?? []).map(mapApiMessage);
setMessages(initial);
// Mark init done BEFORE marking loading=false so any store push
// that arrives in the same tick is treated as live, not init.
initDoneRef.current = true;
setLoading(false);
// Subscribe to live pushes after init is complete.
syncLive();
const unsubscribe = useCanvasStore.subscribe(syncLive);
return unsubscribe; // returned for cleanup
} catch (e) {
if (cancelled) return;
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
setLoading(false);
initDoneRef.current = true;
return undefined;
}
};
let maybeUnsubscribe: (() => void) | undefined;
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
return () => {
cancelled = true;
if (maybeUnsubscribe) maybeUnsubscribe();
};
}, [agentId]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -393,61 +308,7 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && loading && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}></div>
<div>Loading chat history</div>
</div>
)}
{tab === "my" && !loading && historyError && (
<div
role="alert"
style={{
padding: "14px 4px",
textAlign: "center",
color: p.failed,
fontSize: 13,
}}
>
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
<button
type="button"
onClick={() => {
setLoading(true);
setHistoryError(null);
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
(res: unknown) => {
const r = res as ChatHistoryResponse;
setMessages((r.messages ?? []).map((m) => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})));
setLoading(false);
initDoneRef.current = true;
},
).catch((e: unknown) => {
setHistoryError(e instanceof Error ? e.message : "Failed to load");
setLoading(false);
initDoneRef.current = true;
});
}}
style={{
padding: "6px 14px",
borderRadius: 14,
border: `0.5px solid ${p.failed}`,
background: "transparent",
color: p.failed,
fontSize: 12,
cursor: "pointer",
}}
>
Retry
</button>
</div>
)}
{tab === "my" && !loading && !historyError && messages.length === 0 && (
{tab === "my" && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
@@ -2,7 +2,7 @@
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -32,10 +32,7 @@ export function MobileDetail({
onChat: () => void;
}) {
const p = usePalette(dark);
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [tab, setTab] = useState<TabId>("overview");
if (!node) {
@@ -8,19 +8,11 @@
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileChat } from "../MobileChat";
// ─── Mock API ─────────────────────────────────────────────────────────────────
// vi.mock without a factory auto-mocks the module. In tests, we configure
// api.get / api.post directly (they are vi.fn() from the auto-mock).
// Tests that need specific behaviour use mockResolvedValueOnce on the
// auto-mocked functions.
vi.mock("@/lib/api");
import { api } from "@/lib/api";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockAgentId = "ws-chat-test";
@@ -40,14 +32,8 @@ const mockStoreState = {
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
if (sel) return sel(mockStoreState);
return mockStoreState;
}),
{
getState: () => mockStoreState,
subscribe: vi.fn(() => vi.fn()),
},
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
@@ -68,6 +54,16 @@ vi.mock("@/store/canvas", () => ({
}),
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
const { mockApiPost } = vi.hoisted(() => ({
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
}));
vi.mock("@/lib/api", () => ({
api: { post: mockApiPost },
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const onlineNode = {
@@ -154,15 +150,7 @@ beforeEach(() => {
mockOnBack.mockClear();
mockStoreState.nodes = [];
mockStoreState.agentMessages = {};
// Set up spies on the real api methods. Tests override these per-call.
const getSpy = vi.spyOn(api, "get");
const postSpy = vi.spyOn(api, "post");
getSpy.mockResolvedValue({ messages: [], reached_end: true });
postSpy.mockResolvedValue({ result: { parts: [] } });
});
afterEach(() => {
vi.restoreAllMocks();
mockApiPost.mockClear();
});
afterEach(() => {
@@ -278,26 +266,15 @@ describe("MobileChat — empty state", () => {
mockStoreState.nodes = [onlineNode];
});
it('shows "Send a message to start chatting." when no messages', async () => {
// History fetch resolves immediately in tests (mockResolvedValue).
// act() flushes the microtask queue so the component reaches its
// post-load state before we assert.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
it('shows "Send a message to start chatting." when no messages', () => {
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
// Explicitly set to empty to simulate no stored messages
mockStoreState.agentMessages = {};
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
});
@@ -344,132 +321,3 @@ describe("MobileChat — dark mode", () => {
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
});
});
// ─── Chat history loading ────────────────────────────────────────────────────
describe("MobileChat — chat history", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("calls GET /workspaces/:id/chat-history on mount", async () => {
await act(async () => {
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
`/workspaces/${mockAgentId}/chat-history?limit=50`,
);
});
it("shows loading state while history is fetching", () => {
// Do NOT await — check the pre-resolve state.
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Loading chat history…");
});
it("shows empty state after history resolves with no messages", async () => {
// beforeEach already sets api.get to resolve with empty — no override needed.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("renders messages from history response", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-1",
role: "user",
content: "Hello agent",
timestamp: "2026-04-25T10:00:00Z",
},
{
id: "msg-2",
role: "agent",
content: "Hello back",
timestamp: "2026-04-25T10:00:01Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Hello agent");
expect(container.textContent ?? "").toContain("Hello back");
});
it("maps user role from API correctly", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-u",
role: "user",
content: "user message",
timestamp: "2026-04-25T10:00:00Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
// User messages render right-aligned. The text content check is sufficient
// to confirm the message appeared.
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("user message");
});
it("shows error state when history fetch fails", async () => {
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
});
it("Retry button re-fetches history after error", async () => {
// Make the initial mount call fail so the Retry button appears, then
// make the retry call succeed so we can verify the full flow.
const getSpy = vi.spyOn(api, "get");
getSpy
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({ messages: [], reached_end: true });
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
// Error state should be shown with Retry button.
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
// Click Retry — the button's onClick fires api.get again.
// The second mockResolvedValueOnce makes it succeed.
const retryBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Retry",
);
expect(retryBtn).toBeTruthy();
await act(async () => {
retryBtn?.click();
});
// waitFor polls until the retry resolves and component re-renders.
await waitFor(() => {
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
// Initial call + retry = 2.
expect(getSpy).toHaveBeenCalledTimes(2);
});
});
+3 -3
View File
@@ -307,7 +307,7 @@ function ActivityRow({
{/* Error detail */}
{isError && entry.error_detail && (
<div className="text-[9px] text-bad mt-1 truncate">
<div className="text-[9px] text-bad/80 mt-1 truncate">
{entry.error_detail}
</div>
)}
@@ -358,10 +358,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
const hint = inferA2AErrorHint(detail);
return (
<div>
<div className="text-[8px] text-bad uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
<div className="text-[9px] text-bad leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
</div>
</div>
);
+1 -1
View File
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
onClick={handleSave}
disabled={saving}
data-testid="budget-save-btn"
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
+2 -2
View File
@@ -255,7 +255,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</h3>
<button
onClick={() => setShowForm(!showForm)}
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
>
{showForm ? "Cancel" : "+ Connect"}
</button>
@@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<button
onClick={handleDiscover}
disabled={discovering || !formValues["bot_token"]}
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
>
{discovering ? "Detecting..." : "Detect Chats"}
</button>
+9 -34
View File
@@ -962,32 +962,6 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
</div>
)}
{/* talk_to_user disabled banner — shown when the workspace has
talk_to_user_enabled=false. The agent cannot send canvas messages;
the user can re-enable the ability from here without opening settings. */}
{data.talkToUserEnabled === false && (
<div className="flex items-center gap-2 px-3 py-2 bg-surface-sunken border-b border-line/40 shrink-0">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 text-ink-mid">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM8 4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4A.75.75 0 0 1 8 4Z" fill="currentColor"/>
</svg>
<span className="text-[10px] text-ink-mid flex-1">
Agent is not enabled to chat with you.
</span>
<button
onClick={async () => {
try {
await api.patch(`/workspaces/${workspaceId}/abilities`, { talk_to_user_enabled: true });
useCanvasStore.getState().updateNodeData(workspaceId, { talkToUserEnabled: true });
} catch {
// ignore — user will see no change and can retry
}
}}
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
>
Enable
</button>
</div>
)}
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && (
@@ -1003,7 +977,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</p>
<button
onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
>
Retry
</button>
@@ -1037,10 +1011,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user"
// Blue-600 on white = 3.0:1 (WCAG AA FAIL) in light mode.
// Blue-700 on white = 4.5:1 (PASS). In dark mode, blue-600
// on zinc-800 = 4.9:1 (PASS). So: blue-700 light, blue-600 dark.
? "bg-blue-700 text-white border border-blue-800 dark:bg-blue-600 dark:border-blue-700 shadow-sm"
// Solid blue-600 in both modes — `bg-accent` themes
// lighter in dark, dropping white-text contrast to
// ~3:1 (fails AA). blue-600 keeps ~5:1 against white
// on both warm-paper and dark-slate panels.
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
: msg.role === "system"
// Bump the system bubble's opacity in dark — /10
// overlay was nearly invisible against the dark
@@ -1155,7 +1130,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
))}
</div>
)}
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/80" : "text-ink-mid"}`}>
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/70" : "text-ink-mid"}`}>
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
@@ -1195,11 +1170,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
{error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{error}</span>
<span className="text-[10px] text-bad">{error}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
className="text-[11px] px-2 py-0.5 bg-red-800/40 text-bad rounded hover:bg-red-700/50"
>
Restart
</button>
+4 -4
View File
@@ -325,10 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={handleDelete}
// Red-600 on white text = 3.9:1 (WCAG AA FAIL).
// Red-700 = 4.6:1 (PASS). Hover goes DARKER (red-600)
// to signal press. Same pattern as ConfirmDialog/DeleteCascade.
className="px-3 py-1 bg-red-700 hover:bg-red-600 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
// hover:bg-red-500 LIGHTER on white text drops AA;
// flipped to bg-red-700 + focus-visible danger ring,
// matching the ConfirmDialog/DeleteCascade pattern.
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Confirm Delete
</button>
@@ -131,7 +131,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
<button
type="button"
onClick={doRotate}
className="px-3 py-1.5 bg-red-800 hover:bg-red-700 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Rotate
</button>
+2 -2
View File
@@ -226,7 +226,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p id="files-delete-all-msg" className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<div className="flex gap-2">
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
@@ -240,7 +240,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p id="files-delete-one-msg" className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div className="flex gap-2">
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
@@ -32,7 +32,7 @@ export function FilesToolbar({
value={root}
onChange={(e) => setRoot(e.target.value)}
aria-label="File root directory"
className="text-[10px] bg-surface-card text-ink-mid border border-line rounded px-1.5 py-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] bg-surface-card text-ink-mid border border-line rounded px-1.5 py-0.5 outline-none"
>
<option value="/configs">/configs</option>
<option value="/home">/home</option>
@@ -1,181 +1,217 @@
// @vitest-environment jsdom
/**
* Tests for the main FilesTab / PlatformOwnedFilesTab component.
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
*
* Covers: NotAvailablePanel (external runtime), loading/empty/error states,
* FilesToolbar actions, and the /configs-only upload guard.
* NotAvailablePanel: pure presentational component — renders a "feature not
* available" placeholder for external-runtime workspaces.
* FilesToolbar: pure props-driven component — directory selector, file count,
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
*
* No @testing-library/jest-dom — use textContent / className / getAttribute.
* No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { FilesTab } from "../../FilesTab.tsx";
import { FilesToolbar } from "../FilesToolbar.tsx";
import type { FileEntry } from "../../FilesTab/tree";
import { FilesToolbar } from "../FilesToolbar";
import { NotAvailablePanel } from "../NotAvailablePanel";
// ─── Mock ──────────────────────────────────────────────────────────────────
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
}));
// ─── afterEach ─────────────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
_mockGet.mockReset();
vi.restoreAllMocks();
});
// ─── Helpers ───────────────────────────────────────────────────────────────
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
const emptyFileList: FileEntry[] = [];
describe("NotAvailablePanel", () => {
it("renders heading 'Files not available'", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("Files not available");
});
/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) {
return render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }}
{...extraProps}
/>,
);
}
it("renders the runtime name in monospace", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("external");
const spans = container.querySelectorAll("span");
const monoSpans = Array.from(spans).filter(
(s) => s.className && s.className.includes("font-mono"),
);
expect(monoSpans.length).toBeGreaterThan(0);
});
/** Render FilesToolbar directly with stub handlers. */
function renderToolbar(extraProps: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
{...extraProps}
/>
);
}
it("renders a Chat tab hint in description", () => {
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
expect(container.textContent).toContain("Chat tab");
});
// ─── NotAvailablePanel ──────────────────────────────────────────────────────
it("SVG icon has aria-hidden=true", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
describe("FilesTab — NotAvailablePanel", () => {
it("renders NotAvailablePanel when runtime is external", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
it("renders without crashing for any runtime string", () => {
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
expect(container.textContent).toContain("unknown-runtime");
});
it("applies the correct layout classes to root div", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain("flex");
expect(root.className).toContain("flex-col");
expect(root.className).toContain("items-center");
});
});
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
describe("FilesToolbar", () => {
const noop = vi.fn();
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={noop}
fileCount={0}
onNewFile={noop}
onUpload={noop}
onDownloadAll={noop}
onClearAll={noop}
onRefresh={noop}
{...props}
/>,
);
expect(screen.getByText(/Files not available/i)).toBeTruthy();
}
it("renders the directory selector with correct aria-label", () => {
const { container } = renderToolbar();
const select = container.querySelector("select");
expect(select?.getAttribute("aria-label")).toBe("File root directory");
});
it("renders the runtime name in NotAvailablePanel", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
it("directory selector has all four options", () => {
const { container } = renderToolbar();
const select = container.querySelector("select") as HTMLSelectElement;
const options = Array.from(select?.options ?? []);
const values = options.map((o) => o.value);
expect(values).toContain("/configs");
expect(values).toContain("/home");
expect(values).toContain("/workspace");
expect(values).toContain("/plugins");
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
const { container } = renderToolbar({ setRoot });
const select = container.querySelector("select") as HTMLSelectElement;
select.value = "/home";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(setRoot).toHaveBeenCalledWith("/home");
});
it("displays the file count", () => {
const { container } = renderToolbar({ fileCount: 42 });
expect(container.textContent).toContain("42 files");
});
it("shows New + Upload + Clear buttons for /configs", () => {
const { container } = renderToolbar({ root: "/configs" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(screen.getByText(/external/i)).toBeTruthy();
expect(texts).toContain("+ New");
expect(texts).toContain("Upload");
expect(texts).toContain("Clear");
expect(texts).toContain("Export");
expect(texts).toContain("↻");
});
it("does NOT call api.get when runtime is external", async () => {
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
it("hides New + Upload + Clear for /workspace", () => {
const { container } = renderToolbar({ root: "/workspace" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(_mockGet).not.toHaveBeenCalled();
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
expect(texts).toContain("Export");
});
});
// ─── Loading / Empty / Error states ────────────────────────────────────────
describe("FilesTab — states", () => {
it("shows loading text while fetching files", () => {
_mockGet.mockImplementation(
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
renderPlatformTab();
expect(screen.getByText("Loading files...")).toBeTruthy();
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("shows 'No config files yet' when root is /configs and no files", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText(/No config files yet/i)).toBeTruthy();
});
it("hides New + Upload + Clear for /plugins", () => {
const { container } = renderToolbar({ root: "/plugins" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("fetches from the correct endpoint", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
});
it("New button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const newBtn = container.querySelector('button[aria-label="Create new file"]');
expect(newBtn?.textContent?.trim()).toBe("+ New");
});
it("shows file count from toolbar when files exist", async () => {
_mockGet.mockResolvedValue([
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
]);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText("2 files")).toBeTruthy();
});
});
});
// ─── FilesToolbar ──────────────────────────────────────────────────────────
describe("FilesTab — FilesToolbar", () => {
it("shows Refresh button", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByLabelText("Refresh file list")).toBeTruthy();
});
it("Export button has correct aria-label", () => {
const { container } = renderToolbar();
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
expect(exportBtn?.textContent?.trim()).toBe("Export");
});
it("shows root directory selector", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("Clear button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
expect(clearBtn?.textContent?.trim()).toBe("Clear");
});
it("Refresh button triggers a reload", async () => {
// Use persistent mock — loadFiles fires on mount AND on Refresh click.
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByLabelText("Refresh file list"));
const before = _mockGet.mock.calls.length;
fireEvent.click(screen.getByLabelText("Refresh file list"));
await waitFor(() => {
expect(_mockGet.mock.calls.length).toBeGreaterThan(before);
});
it("Refresh button has correct aria-label", () => {
const { container } = renderToolbar();
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
expect(refreshBtn?.textContent?.trim()).toBe("↻");
});
});
// ─── Upload guard ──────────────────────────────────────────────────────────
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
const { container } = renderToolbar({ root: "/configs", onNewFile });
container.querySelector('button[aria-label="Create new file"]')!.click();
expect(onNewFile).toHaveBeenCalledTimes(1);
});
describe("FilesTab — upload guard", () => {
it("no error alert on dragover when root is /configs (default)", async () => {
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByText(/No config files yet/i));
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
const { container } = renderToolbar({ onDownloadAll });
container.querySelector('button[aria-label="Download all files"]')!.click();
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
// No alert should be present
expect(screen.queryByRole("alert")).toBeNull();
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
const { container } = renderToolbar({ root: "/configs", onClearAll });
container.querySelector('button[aria-label="Delete all files"]')!.click();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
const { container } = renderToolbar({ onRefresh });
container.querySelector('button[aria-label="Refresh file list"]')!.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("applies focus-visible ring to all interactive buttons", () => {
@@ -1,218 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for tree.ts — buildTree and getIcon pure functions.
*/
import { describe, expect, it } from "vitest";
import type { FileEntry } from "../tree";
import { buildTree, getIcon } from "../tree";
// ─── getIcon ─────────────────────────────────────────────────────────────────
describe("getIcon", () => {
it("returns folder emoji for directories", () => {
expect(getIcon("/configs", true)).toBe("📁");
});
it("returns correct emoji for .md", () => {
expect(getIcon("readme.md", false)).toBe("📄");
});
it("returns correct emoji for .yaml", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
});
it("returns correct emoji for .yml", () => {
expect(getIcon("config.yml", false)).toBe("⚙");
});
it("returns correct emoji for .py", () => {
expect(getIcon("script.py", false)).toBe("🐍");
});
it("returns correct emoji for .ts", () => {
expect(getIcon("index.ts", false)).toBe("💠");
});
it("returns correct emoji for .tsx", () => {
expect(getIcon("App.tsx", false)).toBe("💠");
});
it("returns correct emoji for .js", () => {
expect(getIcon("index.js", false)).toBe("📜");
});
it("returns correct emoji for .json", () => {
expect(getIcon("package.json", false)).toBe("{}");
});
it("returns correct emoji for .html", () => {
expect(getIcon("index.html", false)).toBe("🌐");
});
it("returns correct emoji for .css", () => {
expect(getIcon("style.css", false)).toBe("🎨");
});
it("returns correct emoji for .sh", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
});
it("returns default file emoji for unknown extensions", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Rakefile", false)).toBe("📄");
});
it("extension matching is case-insensitive", () => {
expect(getIcon("readme.MD", false)).toBe("📄");
expect(getIcon("script.PY", false)).toBe("🐍");
});
});
// ─── buildTree ───────────────────────────────────────────────────────────────
describe("buildTree", () => {
it("returns empty array for empty input", () => {
expect(buildTree([])).toEqual([]);
});
it("adds a single file at root", () => {
const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "config.yaml",
path: "config.yaml",
isDir: false,
children: [],
size: 128,
});
});
it("adds a single directory at root", () => {
const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "skills",
path: "skills",
isDir: true,
children: [],
size: 0,
});
});
it("sorts dirs before files at the same level", () => {
const files: FileEntry[] = [
{ path: "b.txt", size: 10, dir: false },
{ path: "a.txt", size: 10, dir: false },
{ path: "z-dir", size: 0, dir: true },
{ path: "a-dir", size: 0, dir: true },
];
const tree = buildTree(files);
expect(tree).toHaveLength(4);
// Dirs first: z-dir, a-dir alphabetically → a before z
expect(tree[0].name).toBe("a-dir");
expect(tree[1].name).toBe("z-dir");
// Then files alphabetically
expect(tree[2].name).toBe("a.txt");
expect(tree[3].name).toBe("b.txt");
});
it("alphabetically sorts files within the same level", () => {
const files: FileEntry[] = [
{ path: "z.yaml", size: 10, dir: false },
{ path: "a.yaml", size: 10, dir: false },
{ path: "m.yaml", size: 10, dir: false },
];
const tree = buildTree(files);
expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]);
});
it("nests a file under its parent directory", () => {
const files: FileEntry[] = [
{ path: "skills", size: 0, dir: true },
{ path: "skills/readme.md", size: 64, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("skills");
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0]).toMatchObject({
name: "readme.md",
path: "skills/readme.md",
isDir: false,
size: 64,
});
});
it("creates intermediate directories automatically", () => {
const files: FileEntry[] = [
{ path: "a/b/c/deep.txt", size: 32, dir: false },
];
const tree = buildTree(files);
// Root has one child: "a"
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
expect(tree[0].isDir).toBe(true);
// "a" has one child: "b"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b");
// "b" has one child: "c"
expect(tree[0].children[0].children).toHaveLength(1);
expect(tree[0].children[0].children[0].name).toBe("c");
// "c" has the file
expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt");
expect(tree[0].children[0].children[0].children[0].size).toBe(32);
});
it("adds multiple files to the same directory", () => {
const files: FileEntry[] = [
{ path: "configs", size: 0, dir: true },
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]);
});
it("does not duplicate a directory already created as intermediate", () => {
const files: FileEntry[] = [
{ path: "a/b.txt", size: 5, dir: false },
{ path: "a", size: 0, dir: true },
];
const tree = buildTree(files);
// "a" should appear only once
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
// The dir "a" should still contain "b.txt"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b.txt");
});
it("intermediate dirs have size 0", () => {
const files: FileEntry[] = [
{ path: "a/b/c/file.txt", size: 1, dir: false },
];
const tree = buildTree(files);
expect(tree[0].size).toBe(0);
expect(tree[0].children[0].size).toBe(0);
});
it("handles deeply nested mixed dirs and files", () => {
const files: FileEntry[] = [
{ path: "a", size: 0, dir: true },
{ path: "a/b", size: 0, dir: true },
{ path: "a/b/c", size: 0, dir: true },
{ path: "a/b/c/d.txt", size: 1, dir: false },
{ path: "a/b/e.txt", size: 2, dir: false },
{ path: "a/f.txt", size: 3, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1); // root: "a"
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]);
expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort())
.toEqual(["c", "e.txt"]);
});
});
+6 -13
View File
@@ -194,7 +194,7 @@ export function ScheduleTab({ workspaceId }: Props) {
</span>
<button
onClick={() => { resetForm(); setShowForm(true); }}
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
>
+ Add Schedule
</button>
@@ -332,14 +332,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<div className="flex items-center gap-1.5">
<button
onClick={() => handleToggle(sched)}
aria-label={
sched.last_status === "error"
? "Last run failed — click to disable"
: sched.last_status === "ok"
? "Last run OK — click to disable"
: "Never run — click to enable"
}
className={`w-2 h-2 rounded-full flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900 ${
className={`w-2 h-2 rounded-full flex-shrink-0 ${
sched.last_status === "error"
? "bg-red-400"
: sched.last_status === "ok"
@@ -367,7 +360,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<span>Runs: {sched.run_count}</span>
</div>
{sched.last_error && (
<div className="text-[8px] text-bad mt-0.5 truncate">
<div className="text-[8px] text-bad/70 mt-0.5 truncate">
Error: {sched.last_error}
</div>
)}
@@ -376,7 +369,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleRunNow(sched)}
aria-label={`Run schedule ${sched.name} now`}
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
title="Run now"
>
@@ -384,7 +377,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleEdit(sched)}
aria-label={`Edit schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
title="Edit"
>
@@ -392,7 +385,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
aria-label={`Delete schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
title="Delete"
>
+1 -1
View File
@@ -492,7 +492,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="text-[10px] text-bad/80">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
-189
View File
@@ -1,189 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for hydrate.ts — canvas store hydration with exponential backoff.
*
* Covers:
* - Successful hydration on first attempt (no retries)
* - Retry with exponential backoff on failure
* - onRetrying callback called at correct intervals
* - Error propagation after MAX_RETRIES exhausted
* - Viewport persisted on success
* - Viewport failure is non-fatal
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { WorkspaceData } from "@/store/socket";
// ---------------------------------------------------------------------------
// Mock modules — must precede imports that use them
// ---------------------------------------------------------------------------
const mockHydrate = vi.fn();
const mockSetViewport = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
},
PLATFORM_URL: "https://platform.test",
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
() => ({}),
{
getState: () => ({
hydrate: mockHydrate,
setViewport: mockSetViewport,
}),
},
),
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import { api } from "@/lib/api";
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const WORKSPACES: WorkspaceData[] = [
{ id: "ws-1", name: "Test Workspace" } as WorkspaceData,
];
const VIEWPORT = { x: 10, y: 20, zoom: 1.5 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockApiGet = vi.mocked(api.get);
/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */
function succeedTimes(count: number) {
let workspaceRemaining = count;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return VIEWPORT;
if (workspaceRemaining > 0) {
workspaceRemaining--;
return WORKSPACES;
}
throw new Error("API error");
});
}
/** Always fails with the given message. */
function alwaysFail(msg = "Network error") {
mockApiGet.mockRejectedValue(new Error(msg));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("hydrateCanvas", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiGet.mockReset();
mockHydrate.mockReset();
mockSetViewport.mockReset();
});
// ── Success on first attempt ─────────────────────────────────────────────
it("hydrates the store and returns null error on first attempt success", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
});
it("persists viewport when returned by the API", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT);
});
// ── Viewport failure is non-fatal ─────────────────────────────────────────
it("returns null error when viewport fetch fails but workspaces succeed", async () => {
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") throw new Error("Viewport error");
return WORKSPACES;
});
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).not.toHaveBeenCalled();
});
// ── Retry logic ──────────────────────────────────────────────────────────
it("retries MAX_RETRIES times before returning an error", async () => {
alwaysFail();
const onRetrying = vi.fn();
const result = await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly");
expect(result.error).not.toBeNull();
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
}, 10000);
it("onRetrying is called with attempt number before each retry", async () => {
alwaysFail();
const onRetrying = vi.fn();
await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
}, 10000);
it("succeeds on second attempt — hydrates after transient failure", async () => {
let callCount = 0;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return null;
callCount++;
if (callCount === 1) throw new Error("Transient error");
return WORKSPACES;
});
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
}, 10000);
// ── Error messages ────────────────────────────────────────────────────────
it("error message includes the platform URL after all retries exhausted", async () => {
alwaysFail("Connection refused");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).toContain("platform.test");
expect(result.error).toContain("Unable to connect");
}, 10000);
it("error message includes the underlying error message", async () => {
alwaysFail("TLS certificate expired");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).not.toBeNull();
expect(typeof result.error).toBe("string");
}, 10000);
});
@@ -1,205 +0,0 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
*
* Test coverage (9 cases):
* 1. MobileAccentProvider renders children
* 2. usePalette(false) without provider → MOL_LIGHT
* 3. usePalette(true) without provider → MOL_DARK
* 4. accent=null returns base palette unchanged
* 5. accent=base.accent returns base palette unchanged (identity guard)
* 6. accent="#custom" overrides both accent and online
* 7. MOL_LIGHT singleton never mutated
* 8. MOL_DARK singleton never mutated
*
* Plus pure-function coverage for normalizeStatus + tierCode.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import {
MOL_LIGHT,
MOL_DARK,
getPalette,
normalizeStatus,
tierCode,
MobileAccentProvider,
usePalette,
} from "../palette-context";
// ─── usePalette test helper ───────────────────────────────────────────────────
// usePalette reads document.documentElement.dataset.theme internally.
// We set this before rendering so the hook sees the right value.
function setDataTheme(theme: "light" | "dark") {
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = theme;
}
}
// ─── Pure function tests ──────────────────────────────────────────────────────
describe("normalizeStatus", () => {
it("returns emerald-400 for online status", () => {
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
});
it("returns emerald-400 for degraded status", () => {
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
});
it("returns red-400 for failed status", () => {
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
});
it("returns amber-400 for paused status", () => {
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
});
it("returns amber-400 for not_configured status", () => {
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
});
it("returns zinc-400 for unknown status", () => {
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
});
});
describe("tierCode", () => {
it("returns T1 for tier 1", () => {
expect(tierCode(1)).toBe("T1");
});
it("returns T2 for tier 2", () => {
expect(tierCode(2)).toBe("T2");
});
it("returns T4 for tier 4", () => {
expect(tierCode(4)).toBe("T4");
});
it("returns generic T{n} for non-standard tiers", () => {
expect(tierCode(99)).toBe("T99");
});
});
// ─── getPalette tests ─────────────────────────────────────────────────────────
describe("getPalette — accent override", () => {
it("accent=null returns base palette unchanged (light)", () => {
const result = getPalette(null, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
});
it("accent=null returns base palette unchanged (dark)", () => {
const result = getPalette(null, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
const result = getPalette(MOL_LIGHT.accent, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT);
});
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
const result = getPalette(MOL_DARK.accent, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent='#custom' overrides accent and online (light)", () => {
const result = getPalette("#ff0000", false);
expect(result.accent).toBe("#ff0000");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
});
it("accent='#custom' overrides accent and online (dark)", () => {
const result = getPalette("#00ff00", true);
expect(result.accent).toBe("#00ff00");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
});
it("MOL_LIGHT singleton is never mutated", () => {
getPalette("#mutate", false);
// All fields must still match the original freeze definition
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
expect(MOL_LIGHT.line).toBe("border-zinc-700");
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
});
it("MOL_DARK singleton is never mutated", () => {
getPalette("#mutate", true);
expect(MOL_DARK.accent).toBe("bg-sky-400");
expect(MOL_DARK.online).toBe("bg-emerald-400");
expect(MOL_DARK.surface).toBe("bg-zinc-800");
expect(MOL_DARK.ink).toBe("text-zinc-100");
expect(MOL_DARK.line).toBe("border-zinc-700");
expect(MOL_DARK.bg).toBe("bg-zinc-950");
});
it("getPalette always returns a new object (no shared mutation risk)", () => {
const a = getPalette("#a", false);
const b = getPalette("#b", false);
expect(a).not.toBe(b);
expect(a.accent).not.toBe(b.accent);
});
});
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
describe("MobileAccentProvider", () => {
beforeEach(() => {
setDataTheme("light");
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = "";
}
});
it("renders children", () => {
render(
<MobileAccentProvider accent={null}>
<span data-testid="child">Hello</span>
</MobileAccentProvider>,
);
expect(screen.getByTestId("child")).toBeTruthy();
});
// usePalette hook reads data-theme from <html> to determine light/dark.
// In the test environment, data-theme is empty, which falls through to
// the "light" default in usePalette, giving MOL_LIGHT.
it("usePalette(false) without provider → MOL_LIGHT", () => {
setDataTheme("light");
function ShowPalette() {
const p = usePalette(false);
return <span data-testid="accent-light">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
});
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
setDataTheme("dark");
function ShowPalette() {
const p = usePalette(true);
return <span data-testid="accent-dark">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
});
});
+2 -2
View File
@@ -21,8 +21,8 @@ export function statusDotClass(status: string): string {
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-white border-violet-500" },
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-white border-warm" },
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
};
export const COMM_TYPE_LABELS: Record<string, string> = {
-167
View File
@@ -1,167 +0,0 @@
"use client";
/**
* palette-context.tsx
*
* Mobile canvas accent palette system.
*
* - MOL_LIGHT / MOL_DARK — immutable base singletons
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
* - tierCode(tier) — maps tier number → display label
* - MobileAccentProvider — React context that propagates accent override
* - usePalette(allowAccentOverride) — hook; returns the effective palette
*/
import { createContext, useContext } from "react";
// ─── Types ─────────────────────────────────────────────────────────────────────
export interface Palette {
/** Accent colour (CSS colour string). */
accent: string;
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
online: string;
/** Surface background colour class. */
surface: string;
/** Primary text colour class. */
ink: string;
/** Border/divider colour class. */
line: string;
/** Background colour class. */
bg: string;
/** Tier display code, e.g. "T1". */
tier: string;
}
// ─── Singleton base palettes ────────────────────────────────────────────────────
/** Light-mode base palette — must never be mutated. */
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
accent: "bg-blue-500",
online: "bg-emerald-400",
surface: "bg-zinc-900",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
/** Dark-mode base palette — must never be mutated. */
export const MOL_DARK: Readonly<Palette> = Object.freeze({
accent: "bg-sky-400",
online: "bg-emerald-400",
surface: "bg-zinc-800",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/**
* Maps workspace status string → online dot colour class.
* Returns the appropriate green for light/dark mode.
*/
export function normalizeStatus(
status: string,
_isDark: boolean,
): string {
if (status === "online" || status === "degraded") {
return "bg-emerald-400";
}
if (status === "failed") {
return "bg-red-400";
}
if (status === "paused" || status === "not_configured") {
return "bg-amber-400";
}
return "bg-zinc-400";
}
/**
* Maps tier number → display code.
*/
export function tierCode(tier: number): string {
return `T${tier}`;
}
/**
* Returns the effective palette.
*
* - `accent = null` → base palette (light or dark) unchanged
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
* - `accent = "#custom"` → copy with `accent` and `online` overridden
*
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
*/
export function getPalette(
accent: string | null,
isDark: boolean,
): Palette {
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
// null accent → use base unchanged
if (accent === null) return { ...base };
// identity guard — accent same as base accent → no override needed
if (accent === base.accent) return { ...base };
// Custom accent: override accent + online to keep them in sync
return { ...base, accent, online: normalizeStatus("online", isDark) };
}
// ─── Context ──────────────────────────────────────────────────────────────────
type MobileAccentContextValue = {
/** Override accent colour (null = no override, use default). */
accent: string | null;
};
const MobileAccentContext = createContext<MobileAccentContextValue>({
accent: null,
});
export { MobileAccentContext };
/**
* Renders children inside the accent override context.
*/
export function MobileAccentProvider({
accent,
children,
}: {
accent: string | null;
children: React.ReactNode;
}) {
return (
<MobileAccentContext.Provider value={{ accent }}>
{children}
</MobileAccentContext.Provider>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Returns the effective `Palette` for the current context.
*
* @param allowAccentOverride When false, always returns the base palette
* even when an override is set (useful for
* non-accent-aware child components).
*/
export function usePalette(allowAccentOverride: boolean): Palette {
const { accent } = useContext(MobileAccentContext);
// Resolved from the OS-level theme preference. In a real app this would
// be derived from useTheme().resolvedTheme; for this hook we default
// to light (the safe default for SSR / component-library use).
// We read data-theme from <html> to stay in sync with the theme system.
const isDark =
typeof document !== "undefined" &&
document.documentElement.dataset.theme === "dark";
const effectiveAccent = allowAccentOverride ? accent : null;
return getPalette(effectiveAccent, isDark);
}
-4
View File
@@ -519,10 +519,6 @@ export function buildNodesAndEdges(
// #2054 — server-declared per-workspace provisioning timeout.
// Falls through to the runtime profile when null/absent.
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
// Workspace abilities — defaults preserved for old platform versions
// that don't yet include these columns in the GET response.
broadcastEnabled: ws.broadcast_enabled ?? false,
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
},
};
if (hasParent) {
-7
View File
@@ -99,13 +99,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
* expectation without a canvas release. */
provisionTimeoutMs?: number | null;
/** When true the workspace may POST /broadcast to send org-wide messages.
* Default false. Toggled by user/admin via PATCH /workspaces/:id/abilities. */
broadcastEnabled?: boolean;
/** When false the workspace cannot deliver canvas chat messages.
* send_message_to_user / POST /notify return 403 and the canvas
* shows a "not enabled" state with a button to re-enable. Default true. */
talkToUserEnabled?: boolean;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
-3
View File
@@ -299,9 +299,6 @@ export interface WorkspaceData {
* `@/lib/runtimeProfiles` when absent (the default behavior for any
* template that hasn't yet declared the field). */
provision_timeout_ms?: number | null;
/** Workspace ability flags (migration 20260514). */
broadcast_enabled?: boolean;
talk_to_user_enabled?: boolean;
}
let socket: ReconnectingSocket | null = null;
+6 -34
View File
@@ -282,17 +282,13 @@
}
.secret-row__save-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.secret-row__save-btn:hover {
background: #1e40af;
}
.secret-row__save-btn:focus-visible {
@@ -374,17 +370,13 @@
}
.add-key-form__save-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.add-key-form__save-btn:hover {
background: #1e40af;
}
.add-key-form__save-btn:focus-visible {
@@ -518,7 +510,7 @@
.empty-state__body { font-size: 14px; color: #a1a1aa; margin: 0 0 24px; line-height: 1.5; }
.empty-state__cta {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 10px 20px;
@@ -526,10 +518,6 @@
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.empty-state__cta:hover {
background: #1e40af;
}
.empty-state__cta:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
@@ -573,16 +561,12 @@
.secrets-tab__error p { color: var(--status-invalid); margin: 0 0 12px; }
.secrets-tab__refresh-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.secrets-tab__refresh-btn:hover {
background: #1e40af;
}
.secrets-tab__no-results {
@@ -706,16 +690,12 @@
}
.guard-dialog__discard-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.guard-dialog__discard-btn:hover {
background: #1e40af;
}
.guard-dialog__discard-btn:focus-visible {
@@ -767,20 +747,12 @@
.top-bar__name { font-size: 14px; font-weight: 500; color: #d4d4d8; }
.top-bar__btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.top-bar__btn:hover {
background: #1e40af;
}
.top-bar__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #18181b, 0 0 0 4px #3b82f6;
}
+2 -36
View File
@@ -179,7 +179,6 @@ cp_redeploy_tenant() {
# 1 — any other failure
# stdout = response body. stderr = "HTTP_STATUS=NNN" line.
local slug="$1" tag="$2"
validate_slug "$slug"
_mock_call cp_redeploy_tenant "$slug" "$tag"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
local tok="${!CP_TOKEN_ENV:-}"
@@ -205,7 +204,6 @@ cp_redeploy_tenant() {
tenant_buildinfo() {
# args: <slug>; prints JSON
local slug="$1"
validate_slug "$slug"
_mock_call tenant_buildinfo "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
curl -sf --max-time 10 "https://${slug}.moleculesai.app/buildinfo"
@@ -214,7 +212,6 @@ tenant_buildinfo() {
tenant_health() {
# args: <slug>; prints raw response, returns 0 if "ok"
local slug="$1"
validate_slug "$slug"
_mock_call tenant_health "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
curl -sf --max-time 10 "https://${slug}.moleculesai.app/health"
@@ -259,7 +256,6 @@ print(json.dumps({'commands': [ecr_login]}))
resolve_tenant_instance_id() {
# args: <slug>; prints i-xxx
local slug="$1"
validate_slug "$slug"
_mock_call resolve_tenant_instance_id "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
local tok="${!CP_TOKEN_ENV:-}"
@@ -275,19 +271,6 @@ resolve_tenant_instance_id() {
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
err() { printf '[%s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
# validate_slug — exit 64 if slug contains characters outside the safe set.
# Prevents SSRF via query-separator injection (?foo) and subdomain takeover
# (@evil) when slug is interpolated into URL paths or subdomains.
# OFFSEC-006 fix.
validate_slug() {
local slug="$1"
if ! [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then
printf '[%s] ERROR: invalid slug: %s\n' \
"$(date -u +%H:%M:%SZ)" "$slug" >&2
exit 64
fi
}
preflight() {
log "preflight: source=$SOURCE_TAG dest=$DEST_TAG repo=$REPO region=$REGION"
local src_manifest
@@ -356,7 +339,6 @@ promote() {
redeploy_tenant() {
# args: <slug> — handle the 403→SSM-refresh→retry pattern
local slug="$1"
validate_slug "$slug"
log " redeploy: $slug"
if [[ "$DRY_RUN" == "true" ]]; then
log " [dry-run] would POST /redeploy slug=$slug"
@@ -390,7 +372,6 @@ redeploy_tenant() {
verify_tenant() {
local slug="$1"
validate_slug "$slug"
log " verify: $slug"
if [[ "$DRY_RUN" == "true" ]]; then
log " [dry-run] would curl /buildinfo + /health"
@@ -417,7 +398,6 @@ rollback() {
rm -f "$mfile"
IFS=',' read -ra slugs <<<"$TENANTS"
for slug in "${slugs[@]}"; do
validate_slug "$slug"
redeploy_tenant "$slug" || err " rollback redeploy failed for $slug"
done
log "rollback: complete"
@@ -428,13 +408,6 @@ rollback() {
# ─────────────────────────────────────────────────────────────────────────────
main() {
# OFFSEC-006: validate slugs before any network I/O.
IFS=',' read -ra _slugs <<<"$TENANTS"
for _slug in "${_slugs[@]}"; do
validate_slug "$_slug"
done
unset _slugs _slug
preflight || return 1
snapshot_dest_tag || return 2
promote || return 2
@@ -442,15 +415,8 @@ main() {
local promote_rc=0
IFS=',' read -ra slugs <<<"$TENANTS"
for slug in "${slugs[@]}"; do
validate_slug "$slug"
if ! redeploy_tenant "$slug"; then
promote_rc=1
fi
if [[ $promote_rc -eq 0 ]]; then
if ! verify_tenant "$slug"; then
promote_rc=1
fi
fi
redeploy_tenant "$slug" || promote_rc=1
[[ $promote_rc -eq 0 ]] && { verify_tenant "$slug" || promote_rc=1; }
[[ $promote_rc -ne 0 ]] && break
done
+4 -48
View File
@@ -267,51 +267,7 @@ else
printf ' ✗ unknown-flag should fail (got %s)\n' "$rc"
fi
printf '\n== Test 9: slug validation — invalid slugs rejected with exit 64 (OFFSEC-006) ==\n'
# Attack vectors: SSRF via ? (curl query separator), subdomain takeover via @,
# path traversal via /, shell metacharacters. Use a newline-delimited temp file
# so slugs containing spaces are NOT split by shell word-splitting.
_invalid_tmp=$(mktemp)
cat > "$_invalid_tmp" <<'INVALID_EOF'
a?url=https://evil.com
a&url=https://evil.com
a@evil.com
a/b
a\b
a b
chloe-dong?url=http://evil.com
evil.com@legitimate
INVALID_EOF
while IFS= read -r attack || [[ -n "$attack" ]]; do
set +e
out=$("$SCRIPT" --source-tag x --dest-tag y --tenants "$attack" 2>&1); rc=$?
set -e
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'invalid slug'; then
PASS=$((PASS + 1)); printf ' ✓ slug rejected: %s\n' "$(printf '%q' "$attack")"
else
FAIL=$((FAIL + 1)); FAIL_NAMES+=("slug-reject:$attack")
printf ' ✗ slug should be rejected: %s — got exit %s\n' "$(printf '%q' "$attack")" "$rc"
fi
done < "$_invalid_tmp"
rm -f "$_invalid_tmp"
printf '\n== Test 10: slug validation — valid slugs pass through ==\n'
valid_slugs='chloe-dong hongming ab a abc123 my-tenant-42'
for slug in $valid_slugs; do
set +e
out=$("$SCRIPT" --source-tag x --dest-tag y --tenants "$slug" --mock-dir /nonexistent 2>&1); rc=$?
set -e
# valid slugs: script should fail at preflight (no such mock dir / no real infra),
# but NOT at slug validation (exit 64). So we check exit != 64.
if [[ $rc -ne 64 ]]; then
PASS=$((PASS + 1)); printf ' ✓ valid slug accepted: %s\n' "$slug"
else
FAIL=$((FAIL + 1)); FAIL_NAMES+=("slug-accept:$slug")
printf ' ✗ valid slug rejected: %s (should have passed slug check)\n' "$slug"
fi
done
printf '\n== Test 11: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
printf '\n== Test 9: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '{}' 0
mock_set "$m" aws_ecr_describe_image '' 1
@@ -333,7 +289,7 @@ fi
assert_calls_contain "rollback tag uses NOW_OVERRIDE_DATE (20260603)" "$m" 'aws_ecr_put_image b-prev-20260603'
rm -rf "$m"
printf '\n== Test 12: empty source manifest fails preflight ==\n'
printf '\n== Test 10: empty source manifest fails preflight ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '' 0 # rc=0 but empty body (the "None" case)
out=$(run_script "$m")
@@ -341,7 +297,7 @@ assert_exit "empty source manifest fails preflight" "$out" 1
assert_contains "empty manifest message" "$out" 'returned empty manifest'
rm -rf "$m"
printf '\n== Test 13: tenant_buildinfo failure during verify → rollback ==\n'
printf '\n== Test 11: tenant_buildinfo failure during verify → rollback ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
mock_set "$m" aws_ecr_describe_image '' 1
@@ -355,7 +311,7 @@ assert_contains "logs buildinfo failure" "$out" '/buildinfo failed for chloe-don
assert_contains "rollback fired after verify fail" "$out" 'ROLLBACK:'
rm -rf "$m"
printf '\n== Test 14: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
printf '\n== Test 12: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
# Verify the python3 snippet in ssm_refresh_ecr_auth produces valid JSON and
# correctly escapes shell-injection characters in region + account ID fields.
# The fix replaces unquoted shell-printf interpolation with json.dumps.
-296
View File
@@ -1,296 +0,0 @@
#!/usr/bin/env bash
# E2E test: workspace broadcast and talk-to-user platform abilities.
#
# What this proves:
# 1. talk_to_user_enabled (default true) — POST /notify works out-of-the-box.
# 2. PATCH /workspaces/:id/abilities { talk_to_user_enabled: false } disables
# delivery: /notify → 403 with error="talk_to_user_disabled" + delegate hint.
# 3. Re-enabling talk_to_user_enabled restores delivery.
# 4. broadcast_enabled (default false) — POST /broadcast → 403 when disabled.
# 5. PATCH { broadcast_enabled: true } enables fan-out.
# 6. POST /broadcast delivers to all non-sender, non-removed workspaces:
# - Returns {"status":"sent","delivered":N}
# - Receiver's activity log has a broadcast_receive entry with the message.
# - Sender's activity log has a broadcast_sent entry.
# 7. The sender itself does NOT receive a broadcast_receive entry.
#
# Usage: tests/e2e/test_workspace_abilities_e2e.sh
# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production
set -euo pipefail
source "$(dirname "$0")/_lib.sh"
PASS=0
FAIL=0
SENDER_ID=""
RECEIVER_ID=""
cleanup() {
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
if [ -n "$wid" ]; then
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
assert() {
local label="$1" actual="$2" expected="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " expected: $expected"
echo " actual: $actual"
FAIL=$((FAIL+1))
fi
}
assert_contains() {
local label="$1" haystack="$2" needle="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " needle: $needle"
echo " haystack: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_not_contains() {
local label="$1" haystack="$2" needle="$3"
if ! echo "$haystack" | grep -qF "$needle"; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label (unexpected match)"
echo " needle: $needle"
echo " haystack: $haystack"
FAIL=$((FAIL+1))
fi
}
# ── Pre-sweep: remove any stale leftover workspaces from a prior aborted run ──
echo "=== Setup ==="
for NAME in "Abilities Sender" "Abilities Receiver"; do
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c "
import json, sys
try:
print(' '.join(w['id'] for w in json.load(sys.stdin) if w.get('name') == '$NAME'))
except Exception:
pass
")
for _wid in $PRIOR; do
echo "Sweeping leftover '$NAME' workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
done
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Sender","tier":1}')
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
echo "Created sender workspace: $SENDER_ID"
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Receiver","tier":1}')
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
echo "Created receiver workspace: $RECEIVER_ID"
# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod).
SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID")
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN"
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Part 1: talk_to_user ability ==="
echo ""
echo "--- 1a: /notify works with default talk_to_user_enabled=true ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Hello from sender"}')
assert "POST /notify returns 200 when talk_to_user_enabled=true (default)" "$CODE" "200"
echo ""
echo "--- 1b: Disable talk_to_user ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": false}')
assert "PATCH /abilities talk_to_user_enabled=false returns 200" "$CODE" "200"
# Verify the flag is reflected in the workspace GET response.
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
assert "GET /workspaces/:id reflects talk_to_user_enabled=false" "$FLAG" "False"
echo ""
echo "--- 1c: /notify blocked when talk_to_user disabled ---"
BODY=$(curl -s -w "" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
assert "POST /notify returns 403 when talk_to_user_enabled=false" "$CODE" "403"
ERR=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))' 2>/dev/null || echo "")
assert_contains "403 body contains talk_to_user_disabled error code" "$ERR" "talk_to_user_disabled"
HINT=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("hint",""))' 2>/dev/null || echo "")
assert_contains "403 body contains delegate_task hint" "$HINT" "delegate_task"
echo ""
echo "--- 1d: Re-enable talk_to_user and verify /notify works again ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": true}')
assert "PATCH /abilities talk_to_user_enabled=true returns 200" "$CODE" "200"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Re-enabled, should work"}')
assert "POST /notify returns 200 after re-enabling talk_to_user" "$CODE" "200"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Part 2: broadcast ability ==="
echo ""
echo "--- 2a: Broadcast blocked by default (broadcast_enabled=false) ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
assert "POST /broadcast returns 403 when broadcast_enabled=false (default)" "$CODE" "403"
echo ""
echo "--- 2b: Enable broadcast ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"broadcast_enabled": true}')
assert "PATCH /abilities broadcast_enabled=true returns 200" "$CODE" "200"
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
assert "GET /workspaces/:id reflects broadcast_enabled=true" "$FLAG" "True"
echo ""
echo "--- 2c: Successful broadcast fan-out ---"
BCAST=$(curl -s -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Org-wide notice: scheduled maintenance in 5 minutes."}')
BSTATUS=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null || echo "")
BDELIVERED=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("delivered","-1"))' 2>/dev/null || echo "-1")
assert "POST /broadcast returns status=sent" "$BSTATUS" "sent"
# delivered count must be >= 1 (the receiver workspace).
echo " INFO — broadcast delivered=$BDELIVERED"
if python3 -c "import sys; sys.exit(0 if int('$BDELIVERED') >= 1 else 1)" 2>/dev/null; then
echo " PASS — delivered count >= 1"
PASS=$((PASS+1))
else
echo " FAIL — expected delivered >= 1, got $BDELIVERED"
FAIL=$((FAIL+1))
fi
echo ""
echo "--- 2d: Receiver activity log has broadcast_receive entry ---"
RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID")
[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; }
RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN"
ACT=$(curl -s -H "$RECEIVER_AUTH" "$BASE/workspaces/$RECEIVER_ID/activity?source=agent&limit=20")
ROW=$(echo "$ACT" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_receive":
print(json.dumps(r))
break
')
[ -n "$ROW" ] || {
echo " FAIL — could not find broadcast_receive row in receiver activity"
FAIL=$((FAIL+1))
}
if [ -n "$ROW" ]; then
# Message is stored in summary field.
MSG=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("summary",""))')
assert_contains "broadcast_receive row summary has original message" "$MSG" "scheduled maintenance"
# Sender ID is stored in source_id field.
SRC=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("source_id",""))')
assert "broadcast_receive row source_id is sender workspace" "$SRC" "$SENDER_ID"
fi
echo ""
echo "--- 2e: Sender activity log has broadcast_sent entry ---"
ACT_SENDER=$(curl -s -H "$SENDER_AUTH" "$BASE/workspaces/$SENDER_ID/activity?limit=20")
SENT_ROW=$(echo "$ACT_SENDER" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_sent":
print(json.dumps(r))
break
')
[ -n "$SENT_ROW" ] || {
echo " FAIL — could not find broadcast_sent row in sender activity"
FAIL=$((FAIL+1))
}
if [ -n "$SENT_ROW" ]; then
# Delivered count is baked into the summary field (no response_body for sender row).
SUMMARY=$(echo "$SENT_ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("summary",""))')
assert_contains "broadcast_sent summary mentions workspace count" "$SUMMARY" "workspace"
fi
echo ""
echo "--- 2f: Sender does NOT receive a broadcast_receive entry ---"
SELF_RECV=$(echo "$ACT_SENDER" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_receive":
print("found")
break
')
assert_not_contains "sender has no broadcast_receive in own activity log" "${SELF_RECV:-}" "found"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "--- 2g: Empty message is rejected ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":""}')
assert "POST /broadcast with empty message returns 400" "$CODE" "400"
echo ""
echo "--- 2h: Partial PATCH does not clobber other flags ---"
# Set talk_to_user=false, then patch only broadcast — talk_to_user must stay false.
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": false}'
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"broadcast_enabled": false}'
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
TUF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
BEF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
assert "partial PATCH preserves talk_to_user_enabled=false" "$TUF" "False"
assert "partial PATCH sets broadcast_enabled=false" "$BEF" "False"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ]
-151
View File
@@ -22,7 +22,6 @@ Cross-links:
"""
from __future__ import annotations
import re
import subprocess
import sys
import textwrap
@@ -543,153 +542,3 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
_write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
# ---------------------------------------------------------------------------
# Rule 10 — docker info piped to head under pipefail
# ---------------------------------------------------------------------------
DOCKER_INFO_HEAD_BAD = """
name: docker-info-head-bad
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
docker info 2>&1 | head -5 || exit 1
"""
DOCKER_INFO_CAPTURE_OK = """
name: docker-info-capture-ok
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
docker_info="$(docker info 2>&1)" || exit 1
printf '%s\\n' "${docker_info}" | sed -n '1,5p'
"""
DOCKER_INFO_SEPARATE_STEP_OK = """
name: docker-info-separate-step-ok
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
echo setup
- run: |
docker info 2>&1 | head -5 || true
"""
def test_rule10_docker_info_head_under_pipefail_detects_violation(tmp_path):
_write(tmp_path, "bad.yml", DOCKER_INFO_HEAD_BAD)
r = _run_lint(tmp_path)
assert r.returncode == 1
assert "docker info" in r.stdout.lower()
assert "pipefail" in r.stdout.lower()
def test_rule10_docker_info_capture_passes(tmp_path):
_write(tmp_path, "ok.yml", DOCKER_INFO_CAPTURE_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_path):
_write(tmp_path, "ok.yml", DOCKER_INFO_SEPARATE_STEP_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
# ---------------------------------------------------------------------------
# CI change detector fanout — workflow-only PRs keep required contexts without
# running Go/Canvas/Python/shellcheck heavy steps.
# ---------------------------------------------------------------------------
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
text = CI_WORKFLOW.read_text(encoding="utf-8")
patterns: dict[str, re.Pattern[str]] = {}
for surface, pattern in re.findall(
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
text,
):
patterns[surface] = re.compile(pattern)
assert set(patterns) == set(CI_SURFACES)
return patterns
def _classify_ci_change(*paths: str) -> dict[str, bool]:
patterns = _ci_change_patterns()
return {
surface: any(pattern.search(path) for path in paths)
for surface, pattern in patterns.items()
}
def test_ci_change_detector_workflow_only_edits_do_not_trigger_heavy_surfaces():
assert _classify_ci_change(".gitea/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".github/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
def test_ci_change_detector_narrow_surface_edits_only_trigger_their_surface():
assert _classify_ci_change("workspace-server/internal/handlers/foo.go") == {
"platform": True,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change("canvas/app/page.tsx") == {
"platform": False,
"canvas": True,
"python": False,
"scripts": False,
}
assert _classify_ci_change("workspace/a2a_mcp_server.py") == {
"platform": False,
"canvas": False,
"python": True,
"scripts": False,
}
assert _classify_ci_change("tests/e2e/test_model_slug.sh") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": True,
}
def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
assert _classify_ci_change("README.md") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".gitea/scripts/lint-workflow-yaml.py") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
+1 -62
View File
@@ -495,7 +495,7 @@ def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkey
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_pr_without_push_success"] == 1
assert counters["preserved_non_push_suffix"] == 1
assert calls == []
@@ -1009,64 +1009,3 @@ def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
captured = capsys.readouterr()
assert "::warning::" in captured.out or "::notice::" in captured.out
assert SHA_A[:10] in captured.out
def test_main_soft_skips_when_commit_listing_times_out(sr_module, monkeypatch, capsys):
"""A transient outage while listing recent commits should not paint main red.
Per-SHA status read failures are already isolated inside `reap_branch`.
The real 2026-05-14 failure was earlier: `/commits?sha=main&limit=30`
timed out after all retries, aborting the tick. The next 5-minute tick can
retry safely, so `main()` should emit an observable warning and return 0.
"""
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
def fake_list_recent_commit_shas(*args, **kwargs):
raise sr_module.ApiError(
"GET /repos/owner/repo/commits failed after 4 attempts: timed out"
)
monkeypatch.setattr(sr_module, "list_recent_commit_shas", fake_list_recent_commit_shas)
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
assert sr_module.main() == 0
captured = capsys.readouterr()
assert "::warning::status-reaper skipped this tick" in captured.out
assert '"skipped": true' in captured.out
assert '"skip_reason": "commit-list-api-error"' in captured.out
def test_main_does_not_soft_skip_status_write_failures(sr_module, monkeypatch):
"""Only commit-list read failures are soft-skipped.
A compensation write failure means the reaper could not repair a red
status. That must still fail the job loudly instead of being mislabeled as
a transient commit-list outage.
"""
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
monkeypatch.setattr(sr_module, "list_recent_commit_shas", lambda *_args, **_kwargs: [SHA_A])
monkeypatch.setattr(
sr_module,
"get_combined_status",
lambda _sha: {
"state": "failure",
"statuses": [
{
"context": "workflow-without-push / job (push)",
"status": "failure",
"description": "stranded class-O red",
}
],
},
)
def fake_post_compensating_status(*args, **kwargs):
raise sr_module.ApiError("POST /statuses failed: 403")
monkeypatch.setattr(sr_module, "post_compensating_status", fake_post_compensating_status)
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
with pytest.raises(sr_module.ApiError, match="POST /statuses failed"):
sr_module.main()
-3
View File
@@ -18,7 +18,6 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.19.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
@@ -34,7 +33,6 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -60,7 +58,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -1,261 +0,0 @@
package bundle
import (
"os"
"path/filepath"
"testing"
)
// ---------------------------------------------------------------------------
// extractDescription
// ---------------------------------------------------------------------------
func TestExtractDescription_WithFrontmatter(t *testing.T) {
// YAML frontmatter is skipped; first non-comment, non-empty line after
// the closing `---` is the description.
content := `---
title: My Workspace
---
# This is a comment
This is the description line.
Another line.`
got := extractDescription(content)
if got != "This is the description line." {
t.Errorf("got %q, want %q", got, "This is the description line.")
}
}
func TestExtractDescription_NoFrontmatter(t *testing.T) {
// No frontmatter: first non-comment, non-empty line is returned.
content := `# Copyright header
My workspace description
Another line.`
got := extractDescription(content)
if got != "My workspace description" {
t.Errorf("got %q, want %q", got, "My workspace description")
}
}
func TestExtractDescription_CommentOnly(t *testing.T) {
// All content is comments or empty → empty string.
content := `# comment only
# another comment
`
got := extractDescription(content)
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_EmptyInput(t *testing.T) {
got := extractDescription("")
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
// With no closing `---`, inFrontmatter stays true after the opening
// delimiter, so all subsequent lines are skipped and "" is returned.
// This is the documented behaviour: without a closing delimiter,
// all lines are considered frontmatter.
content := `---
title: No closing delimiter
This is the description.`
got := extractDescription(content)
if got != "" {
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
}
}
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
content := `---
tags: [test]
---
# internal comment
Real description here.
`
got := extractDescription(content)
if got != "Real description here." {
t.Errorf("got %q, want %q", got, "Real description here.")
}
}
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
// skipped because len(line)>0. First non-comment, non-empty line is returned.
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
got := extractDescription(content)
if got != "A. Description" {
t.Errorf("got %q, want %q", got, "A. Description")
}
}
// ---------------------------------------------------------------------------
// splitLines
// ---------------------------------------------------------------------------
func TestSplitLines_Basic(t *testing.T) {
got := splitLines("a\nb\nc")
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("len=%d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
}
}
}
func TestSplitLines_TrailingNewline(t *testing.T) {
got := splitLines("line1\nline2\n")
want := []string{"line1", "line2"}
if len(got) != len(want) {
t.Errorf("trailing newline: got %v, want %v", got, want)
}
}
func TestSplitLines_NoNewline(t *testing.T) {
got := splitLines("no newline")
want := []string{"no newline"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("got %v, want %v", got, want)
}
}
func TestSplitLines_EmptyString(t *testing.T) {
got := splitLines("")
if len(got) != 0 {
t.Errorf("empty string: got %v, want []", got)
}
}
func TestSplitLines_OnlyNewlines(t *testing.T) {
got := splitLines("\n\n\n")
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
// the empty string between newlines → 3 empty segments.
// (No trailing segment because start == len(s) at the end.)
if len(got) != 3 {
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
}
for i, s := range got {
if s != "" {
t.Errorf("got[%d]=%q, want empty string", i, s)
}
}
}
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
got := splitLines("a\n\n\nb")
// a\n\n\nb → ["a", "", "", "b"]
if len(got) != 4 {
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
}
if got[0] != "a" || got[3] != "b" {
t.Errorf("first/last: got %v, want [a, ..., b]", got)
}
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_NameMatch(t *testing.T) {
tmp := t.TempDir()
// Create two sub-dirs; only the one with matching name should be found.
mustMkdir(filepath.Join(tmp, "workspace-a"))
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
"name: other-workspace\ntier: 1\n")
mustMkdir(filepath.Join(tmp, "workspace-b"))
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
"name: target-workspace\nruntime: claude-code\n")
got := findConfigDir(tmp, "target-workspace")
want := filepath.Join(tmp, "workspace-b")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "first"))
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
mustMkdir(filepath.Join(tmp, "second"))
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
// No exact name match → fallback to the first directory with a config.yaml.
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "first")
if got != want {
t.Errorf("no match: got %q, want fallback %q", got, want)
}
}
func TestFindConfigDir_MissingDir(t *testing.T) {
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
if got != "" {
t.Errorf("missing dir: got %q, want empty string", got)
}
}
func TestFindConfigDir_NoSubdirs(t *testing.T) {
tmp := t.TempDir()
// Empty directory → no matches, no fallback.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("empty dir: got %q, want empty string", got)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMkdir(path string) {
os.MkdirAll(path, 0o755)
}
func mustWrite(path, content string) {
os.WriteFile(path, []byte(content), 0o644)
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "empty-skill"))
// Sub-dir without config.yaml → skipped.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("no config.yaml: got %q, want empty string", got)
}
}
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
// When name doesn't match, fallback is the FIRST dir with config.yaml,
// not the last. Confirm ordering by creating three dirs.
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "a"))
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
mustMkdir(filepath.Join(tmp, "b"))
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
mustMkdir(filepath.Join(tmp, "c"))
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "a") // first dir with config.yaml
if got != want {
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
}
}
@@ -1,317 +0,0 @@
package bundle
import (
"testing"
)
func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) {
b := &Bundle{}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("empty bundle: want 0 files, got %d", len(files))
}
}
func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) {
b := &Bundle{
SystemPrompt: "You are a helpful assistant.",
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 1 {
t.Fatalf("system-prompt only: want 1 file, got %d", n)
}
if content, ok := files["system-prompt.md"]; !ok {
t.Fatal("missing system-prompt.md")
} else if string(content) != "You are a helpful assistant." {
t.Errorf("system-prompt content: got %q", string(content))
}
}
func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) {
b := &Bundle{
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\ntier: 2\n",
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 1 {
t.Fatalf("config.yaml only: want 1 file, got %d", n)
}
if content, ok := files["config.yaml"]; !ok {
t.Fatal("missing config.yaml")
} else if string(content) != "runtime: langgraph\ntier: 2\n" {
t.Errorf("config.yaml content: got %q", string(content))
}
}
func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "Be concise.",
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\n",
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 2 {
t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n)
}
if _, ok := files["system-prompt.md"]; !ok {
t.Error("missing system-prompt.md")
}
if _, ok := files["config.yaml"]; !ok {
t.Error("missing config.yaml")
}
}
func TestBuildBundleConfigFiles_Skills(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "web-search",
Files: map[string]string{"readme.md": "# Web Search\n"},
},
{
ID: "code-interpreter",
Files: map[string]string{"readme.md": "# Code Interpreter\n"},
},
},
}
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)
}
if _, ok := files["skills/web-search/readme.md"]; !ok {
t.Error("missing skills/web-search/readme.md")
}
if _, ok := files["skills/code-interpreter/readme.md"]; !ok {
t.Error("missing skills/code-interpreter/readme.md")
}
}
func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "multi-file",
Files: map[string]string{
"readme.md": "# Multi",
"instructions.txt": "Step 1, Step 2",
},
},
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 2 {
t.Fatalf("skill with sub-paths: want 2 files, got %d", n)
}
if _, ok := files["skills/multi-file/readme.md"]; !ok {
t.Error("missing skills/multi-file/readme.md")
}
if _, ok := files["skills/multi-file/instructions.txt"]; !ok {
t.Error("missing skills/multi-file/instructions.txt")
}
}
func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) {
b := &Bundle{
SystemPrompt: "",
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\n",
},
}
files := buildBundleConfigFiles(b)
// Empty system-prompt should not produce a file
if n := len(files); n != 1 {
t.Errorf("empty system-prompt: want 1 file, got %d", n)
}
}
func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) {
b := &Bundle{
Prompts: map[string]string{},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 0 {
t.Errorf("empty prompts map: want 0 files, got %d", n)
}
}
func TestBuildBundleConfigFiles_emptyBundle(t *testing.T) {
b := &Bundle{}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("expected empty map for empty bundle, got %d entries", len(files))
}
}
func TestBuildBundleConfigFiles_systemPrompt(t *testing.T) {
b := &Bundle{SystemPrompt: "You are a helpful assistant."}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files["system-prompt.md"]) != "You are a helpful assistant." {
t.Errorf("unexpected system prompt content: %q", files["system-prompt.md"])
}
}
func TestBuildBundleConfigFiles_configYaml(t *testing.T) {
b := &Bundle{Prompts: map[string]string{
"config.yaml": "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n",
}}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files["config.yaml"]) != "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n" {
t.Errorf("unexpected config.yaml content: %q", files["config.yaml"])
}
}
func TestBuildBundleConfigFiles_systemPromptAndConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "# System",
Prompts: map[string]string{"config.yaml": "runtime: langgraph"},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if _, ok := files["system-prompt.md"]; !ok {
t.Error("missing system-prompt.md")
}
if _, ok := files["config.yaml"]; !ok {
t.Error("missing config.yaml")
}
}
func TestBuildBundleConfigFiles_skills(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "web-search",
Name: "Web Search",
Description: "Search the web",
Files: map[string]string{"readme.md": "# Web Search"},
},
{
ID: "code-runner",
Name: "Code Runner",
Description: "Execute code",
Files: map[string]string{"handler.py": "print('hello')"},
},
},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 skill files, got %d", len(files))
}
if content, ok := files["skills/web-search/readme.md"]; !ok {
t.Error("missing skills/web-search/readme.md")
} else if string(content) != "# Web Search" {
t.Errorf("unexpected readme.md: %q", content)
}
if _, ok := files["skills/code-runner/handler.py"]; !ok {
t.Error("missing skills/code-runner/handler.py")
}
}
func TestBuildBundleConfigFiles_skillsWithSubPaths(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "nested-skill",
Files: map[string]string{"src/main.py": "def main(): pass", "pyproject.toml": "[tool.foo]"},
},
},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if _, ok := files["skills/nested-skill/src/main.py"]; !ok {
t.Error("missing skills/nested-skill/src/main.py")
}
if _, ok := files["skills/nested-skill/pyproject.toml"]; !ok {
t.Error("missing skills/nested-skill/pyproject.toml")
}
}
func TestBuildBundleConfigFiles_skipsEmptyPrompts(t *testing.T) {
b := &Bundle{Prompts: map[string]string{}}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("expected 0 files for empty prompts map, got %d", len(files))
}
}
func TestBuildBundleConfigFiles_skipsMissingConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "# My Prompt",
Prompts: map[string]string{"other.yaml": "something: else"},
}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file (system-prompt only), got %d", len(files))
}
if _, ok := files["config.yaml"]; ok {
t.Error("config.yaml should not be written when not in Prompts")
}
}
func TestNilIfEmpty_emptyString(t *testing.T) {
result := nilIfEmpty("")
if result != nil {
t.Errorf("expected nil for empty string, got %v", result)
}
}
func TestNilIfEmpty_nonEmptyString(t *testing.T) {
result := nilIfEmpty("hello")
if result == nil {
t.Fatal("expected non-nil result for non-empty string")
}
if result != "hello" {
t.Errorf("expected hello, got %q", result)
}
}
func TestNilIfEmpty_whitespaceString(t *testing.T) {
// Whitespace is not empty — nilIfEmpty only checks for zero-length
result := nilIfEmpty(" ")
if result == nil {
t.Error("expected non-nil for whitespace string")
} else if result != " " {
t.Errorf("expected ' ', got %q", result)
}
}
func TestNilIfEmpty_EmptyString(t *testing.T) {
got := nilIfEmpty("")
if got != nil {
t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got)
}
}
func TestNilIfEmpty_NonEmptyString(t *testing.T) {
got := nilIfEmpty("hello")
if got == nil {
t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil")
}
if s, ok := got.(string); !ok || s != "hello" {
t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got)
}
}
func TestNilIfEmpty_Whitespace(t *testing.T) {
got := nilIfEmpty(" ")
if got == nil {
t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)")
}
if s, ok := got.(string); !ok || s != " " {
t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got)
}
}
@@ -402,7 +402,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
return err
}
adapter, ok := GetSendAdapter(ch.ChannelType)
adapter, ok := GetAdapter(ch.ChannelType)
if !ok {
return fmt.Errorf("no adapter for %s", ch.ChannelType)
}
@@ -1,7 +1,5 @@
package channels
import "context"
// Registry of all available channel adapters.
// To add a new platform: implement ChannelAdapter, register here.
var adapters = map[string]ChannelAdapter{
@@ -11,27 +9,6 @@ var adapters = map[string]ChannelAdapter{
"discord": &DiscordAdapter{},
}
// SendAdapter is the subset of ChannelAdapter needed by SendOutbound.
// Extracted so tests can inject a no-op/mock adapter without hitting real
// platform APIs (Telegram Bot API, Slack API, etc.).
type SendAdapter interface {
SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error
}
// getSendAdapter is the production implementation of GetSendAdapter —
// returns the real registered adapter's SendMessage method.
func getSendAdapter(channelType string) (SendAdapter, bool) {
a, ok := adapters[channelType]
if !ok {
return nil, false
}
return a, true
}
// GetSendAdapter returns the SendAdapter for a channel type.
// Defaults to the real adapter; overridden by SetTestSendAdapter in tests.
var GetSendAdapter = getSendAdapter
// GetAdapter returns the adapter for a channel type.
func GetAdapter(channelType string) (ChannelAdapter, bool) {
a, ok := adapters[channelType]
@@ -1,30 +0,0 @@
package channels
import "context"
// MockSendAdapter implements SendAdapter for handler tests. It records every
// call and returns a configurable error (nil = success, non-nil = failure).
type MockSendAdapter struct {
Calls int
Err error
SentText string
SentChat string
}
func (m *MockSendAdapter) SendMessage(_ context.Context, _ map[string]interface{}, chatID string, text string) error {
m.Calls++
m.SentText = text
m.SentChat = chatID
return m.Err
}
// SetGetSendAdapter replaces the package-level GetSendAdapter variable.
// Tests MUST call ResetSendAdapters() in their t.Cleanup.
func SetGetSendAdapter(fn func(string) (SendAdapter, bool)) {
GetSendAdapter = fn
}
// ResetSendAdapters restores GetSendAdapter to the production implementation.
func ResetSendAdapters() {
GetSendAdapter = getSendAdapter
}
@@ -645,7 +645,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
// the caller can retry once the workspace is back online (~10s).
if status == "hibernated" {
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
h.goAsync(func() { h.RestartByID(workspaceID) })
go h.RestartByID(workspaceID)
return "", &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": "15"},
@@ -1,408 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
// setupQueueStatusHandlerDB creates a sqlmock DB with QueryMatcherEqual for exact SQL string matching.
func setupQueueStatusHandlerDB(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
return mock
}
// Exact SQL strings used by the production code.
const (
sqlQueueRowAuthFields = `SELECT caller_id, workspace_id FROM a2a_queue WHERE id = $1`
sqlQueueStatusByID = `
SELECT
q.id,
q.workspace_id,
q.status,
q.priority,
q.attempts,
q.last_error,
q.enqueued_at::text,
q.dispatched_at::text,
q.completed_at::text,
q.expires_at::text,
al.response_body::text
FROM a2a_queue q
LEFT JOIN activity_logs al
ON al.method = 'delegate_result'
AND al.target_id = q.workspace_id
AND al.workspace_id = q.caller_id
AND al.response_body->>'delegation_id' = (q.body->'params'->'message'->'metadata'->>'delegation_id')
WHERE q.id = $1`
)
// ── GetA2AQueueStatus HTTP handler tests ──────────────────────────────────────
// TestGetA2AQueueStatus_QueueIDEmpty_Returns400 exercises the handler directly
// (not via router) so we can verify the empty-value branch without relying on
// Gin route-matching behaviour.
func TestGetA2AQueueStatus_QueueIDEmpty_Returns400(t *testing.T) {
gin.SetMode(gin.TestMode)
setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}
// queue_id param is empty string
c.Params = gin.Params{
{Key: "id", Value: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},
{Key: "queue_id", Value: ""},
}
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
}
func TestGetA2AQueueStatus_NoIdentity_NoOrgToken_Returns404(t *testing.T) {
gin.SetMode(gin.TestMode)
setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/wsid/a2a/queue/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// No identity derivable → 404 (not 401) per existence-non-inference policy.
if w.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", w.Code)
}
}
func TestGetA2AQueueStatus_OrgToken_SkipsCallerCheck(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("other-ws", wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
statusRows := sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, wsID, "queued", 50, 0,
nil, "2026-01-01T00:00:00Z", nil, nil, nil, nil,
)
mock.ExpectQuery(sqlQueueStatusByID).
WithArgs(queueID).
WillReturnRows(statusRows)
r := gin.New()
// Simulate org-token middleware setting org_token_id.
r.GET("/workspaces/:id/a2a/queue/:queue_id", func(c *gin.Context) {
c.Set("org_token_id", "org-admin")
h.GetA2AQueueStatus(c)
})
req := httptest.NewRequest(http.MethodGet, "/workspaces/wsid/a2a/queue/"+queueID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_CallerWorkspaceMatchesCallerID_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow(callerID, wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
statusRows := sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, wsID, "completed", 50, 1,
nil, "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z",
nil, []byte(`{"text":"result"}`),
)
mock.ExpectQuery(sqlQueueStatusByID).
WithArgs(queueID).
WillReturnRows(statusRows)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", callerID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_CallerWorkspaceMatchesWorkspaceID_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow(callerID, wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
statusRows := sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, wsID, "queued", 50, 0,
nil, "2026-01-01T00:00:00Z", nil, nil, nil, nil,
)
mock.ExpectQuery(sqlQueueStatusByID).
WithArgs(queueID).
WillReturnRows(statusRows)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", wsID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_QueueNotFound_Returns404(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnError(sql.ErrNoRows)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", callerID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_QueueAuthFieldsDBError_Returns500(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnError(sql.ErrConnDone)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", callerID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("got %d, want 500: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_WrongCallerWorkspace_Returns404(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
wrongCaller := "dddddddd-dddd-dddd-dddd-dddddddddddd"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow(callerID, wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", wrongCaller)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_StatusFetchDBError_Returns500(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow(callerID, wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
mock.ExpectQuery(sqlQueueStatusByID).
WithArgs(queueID).
WillReturnError(sql.ErrConnDone)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", callerID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("got %d, want 500: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestGetA2AQueueStatus_FullHappyPath_ReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := setupQueueStatusHandlerDB(t)
h := &WorkspaceHandler{}
queueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
callerID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
wsID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
authRows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow(callerID, wsID)
mock.ExpectQuery(sqlQueueRowAuthFields).
WithArgs(queueID).
WillReturnRows(authRows)
respBody := []byte(`{"text":"delegation result"}`)
statusRows := sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, wsID, "completed", 50, 1,
nil, "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z",
nil, respBody,
)
mock.ExpectQuery(sqlQueueStatusByID).
WithArgs(queueID).
WillReturnRows(statusRows)
r := gin.New()
r.GET("/workspaces/:id/a2a/queue/:queue_id", h.GetA2AQueueStatus)
req := httptest.NewRequest(http.MethodGet,
"/workspaces/"+wsID+"/a2a/queue/"+queueID, nil)
req.Header.Set("X-Workspace-ID", wsID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
if w.Body.Len() == 0 {
t.Error("response body is empty")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -32,9 +32,8 @@ func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
t.Cleanup(func() { mockDB.Close() })
return mock
}
@@ -81,54 +80,6 @@ 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}}`, 30},
}
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
@@ -482,13 +482,6 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
if errors.Is(err, ErrTalkToUserDisabled) {
c.JSON(http.StatusForbidden, gin.H{
"error": "talk_to_user_disabled",
"hint": "This workspace is not allowed to send messages directly to the user. Forward your update to a parent workspace using delegate_task — they may be able to reach the user.",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
@@ -388,13 +388,9 @@ func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
// ---------- Activity type allowlist (#125: memory_write added) ----------
func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, mock, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
@@ -417,13 +413,9 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
}
func TestActivityReport_RejectsUnknownType(t *testing.T) {
mockDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, _, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
@@ -455,18 +447,14 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
// - Have source_id NULL (canvas-source filter)
// - Carry the message text in response_body so extractResponseText
// can reconstruct the agent reply on reload
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, mock, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
// Workspace existence check
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
mock.ExpectQuery(`SELECT name FROM workspaces`).
WithArgs("ws-notify").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
// Persistence INSERT — verify shape
mock.ExpectExec(`INSERT INTO activity_logs`).
@@ -503,17 +491,13 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
// download chips after a page reload. Without `parts`, the bubble
// shows up but the attachment chip is silently dropped on every
// refresh.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, mock, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
mock.ExpectQuery(`SELECT name FROM workspaces`).
WithArgs("ws-attach").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
// Capture the JSONB arg so we can assert on the persisted shape
// AFTER the call (must include parts[].kind=file so reload
@@ -581,13 +565,9 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, _, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
// No DB expectations — handler must reject with 400 BEFORE
// reaching SELECT/INSERT. sqlmock will fail "expectations not met"
// only if the handler unexpectedly queries.
@@ -632,17 +612,13 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
// WebSocket push (which the user is already seeing in their open
// canvas). Pre-fix the WS push always succeeded; we don't want
// the new persistence step to regress that path.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
mockDB, mock, _ := sqlmock.New()
defer mockDB.Close()
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
mock.ExpectQuery(`SELECT name FROM workspaces`).
WithArgs("ws-x").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnError(fmt.Errorf("simulated db hiccup"))
@@ -54,11 +54,6 @@ import (
// timeout) surface as wrapped errors and should be treated as 503.
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
// ErrTalkToUserDisabled is returned when the workspace has
// talk_to_user_enabled=false. Callers surface HTTP 403 so the Python tool
// can detect it and suggest forwarding to a parent workspace.
var ErrTalkToUserDisabled = errors.New("agent_message: talk_to_user disabled")
// AgentMessageAttachment is one file attached to an agent → user
// message. Identical to handlers.NotifyAttachment in field set; kept
// distinct so the writer's API doesn't import a handler type with HTTP
@@ -112,20 +107,16 @@ func (w *AgentMessageWriter) Send(
// notify call surfaced as "workspace not found" and masked real
// incidents in the alert path.
var wsName string
var talkToUserEnabled bool
err := w.db.QueryRowContext(ctx,
`SELECT name, talk_to_user_enabled FROM workspaces WHERE id = $1 AND status != 'removed'`,
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`,
workspaceID,
).Scan(&wsName, &talkToUserEnabled)
).Scan(&wsName)
if errors.Is(err, sql.ErrNoRows) {
return ErrWorkspaceNotFound
}
if err != nil {
return fmt.Errorf("agent_message: workspace lookup: %w", err)
}
if !talkToUserEnabled {
return ErrTalkToUserDisabled
}
// 2. Build broadcast payload + WS-emit. Same shape that ChatTab's
// AGENT_MESSAGE handler in canvas/src/store/canvas-events.ts has
@@ -88,9 +88,9 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
WithArgs(
@@ -116,9 +116,9 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-att").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
WithArgs(
@@ -173,9 +173,9 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-missing").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}))
WillReturnRows(sqlmock.NewRows([]string{"name"}))
err := w.Send(context.Background(), "ws-missing", "lost in the void", nil)
if !errors.Is(err, ErrWorkspaceNotFound) {
@@ -202,9 +202,9 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-dbfail").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnError(errors.New("transient db error"))
@@ -223,9 +223,9 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-trunc").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
longMsg := strings.Repeat("x", 200)
mock.ExpectExec(`INSERT INTO activity_logs`).
@@ -263,9 +263,9 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-bc").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Workspace Name", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Workspace Name"))
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
@@ -315,7 +315,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
transientErr := errors.New("connection refused")
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-dbdown").
WillReturnError(transientErr)
@@ -350,9 +350,9 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
// the byte-slice bug.
msg := strings.Repeat("你", 200)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-cjk").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(
@@ -395,9 +395,9 @@ func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-noatt").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("X", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("X"))
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
@@ -116,9 +116,6 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("ListPendingApprovals rows.Err: %v", err)
}
c.JSON(http.StatusOK, approvals)
}
@@ -158,9 +155,6 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("ListApprovals rows.Err workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, approvals)
}
@@ -15,7 +15,6 @@ import (
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
@@ -328,207 +327,6 @@ func TestChannelHandler_Send_EmptyText(t *testing.T) {
}
}
// ==================== Test (send outbound) ====================
// TestChannelHandler_Test_Success exercises the /channels/:channelId/test endpoint
// with a mock SendAdapter so the full success path is covered without hitting real
// Telegram/Slack/etc. APIs.
func TestChannelHandler_Test_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
mockAdapter := &channels.MockSendAdapter{Err: nil}
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
if ct == "telegram" {
return mockAdapter, true
}
return channels.GetSendAdapter(ct)
})
t.Cleanup(channels.ResetSendAdapters)
// loadChannel → valid row
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-test-ok").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}).AddRow("ch-test-ok", "ws-1", "telegram",
`{"bot_token":"123:AAA","chat_id":"-100"}`,
true, `[]`))
// UPDATE message_count + last_message_at
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-test-ok/test", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-test-ok"}}
handler.Test(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("expected status 'ok', got %v", resp["status"])
}
if mockAdapter.Calls != 1 {
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
}
if mockAdapter.SentChat != "-100" {
t.Errorf("expected chat_id '-100', got %q", mockAdapter.SentChat)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Test_ChannelNotFound verifies that when loadChannel returns
// no rows, the Test handler returns 500 with a "test message failed" error.
func TestChannelHandler_Test_ChannelNotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
// loadChannel → no rows
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-missing").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-missing/test", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-missing"}}
handler.Test(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["error"] != "test message failed" {
t.Errorf("expected error 'test message failed', got %v", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Send_Success covers the full outbound send success path:
// budget check passes → loadChannel → mock SendMessage succeeds → UPDATE count → 200.
func TestChannelHandler_Send_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
mockAdapter := &channels.MockSendAdapter{Err: nil}
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
if ct == "telegram" {
return mockAdapter, true
}
return channels.GetSendAdapter(ct)
})
t.Cleanup(channels.ResetSendAdapters)
// Budget check: count=0, no budget limit
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
WithArgs("ch-send-ok").
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
AddRow(0, nil))
// loadChannel → valid row
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-send-ok").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}).AddRow("ch-send-ok", "ws-1", "telegram",
`{"bot_token":"123:AAA","chat_id":"-100"}`,
true, `[]`))
// UPDATE message_count
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
WillReturnResult(sqlmock.NewResult(0, 1))
body, _ := json.Marshal(map[string]string{"text": "hello from test"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-ok/send", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-ok"}}
handler.Send(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "sent" {
t.Errorf("expected status 'sent', got %v", resp["status"])
}
if mockAdapter.Calls != 1 {
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
}
if mockAdapter.SentText != "hello from test" {
t.Errorf("expected 'hello from test', got %q", mockAdapter.SentText)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Send_ChannelNotFound verifies that after the budget check
// passes, a missing channel returns 500 (not 404) with "send failed".
func TestChannelHandler_Send_ChannelNotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
// Budget check passes (NULL budget → no limit)
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
WithArgs("ch-send-missing").
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
AddRow(0, nil))
// loadChannel → no rows
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-send-missing").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}))
body, _ := json.Marshal(map[string]string{"text": "hello"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-missing/send", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-missing"}}
handler.Send(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["error"] != "send failed" {
t.Errorf("expected error 'send failed', got %v", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// ==================== Webhook ====================
func TestChannelHandler_Webhook_UnknownType(t *testing.T) {
@@ -566,20 +364,6 @@ func TestChannelHandler_Discover_MissingToken(t *testing.T) {
}
func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
t.Cleanup(func() { mockDB.Close() })
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB })
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
WithArgs("ws-test").
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
handler := NewChannelHandler(newTestChannelManager())
// #329: workspace_id required — include so we actually reach the
@@ -603,20 +387,6 @@ func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
}
func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) {
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
t.Cleanup(func() { mockDB.Close() })
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB })
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
WithArgs("ws-test").
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
handler := NewChannelHandler(newTestChannelManager())
body, _ := json.Marshal(map[string]interface{}{
@@ -262,20 +262,14 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
"task": body.Task,
"delegation_id": delegationID,
})
// Store delegation_id in response_body so agent check_delegation_status
// (which reads response_body->>delegation_id) can locate this row even
// when request_body hasn't propagated yet. Fixes mc#984.
respJSON, _ := json.Marshal(map[string]interface{}{
"delegation_id": delegationID,
})
var idemArg interface{}
if body.IdempotencyKey != "" {
idemArg = body.IdempotencyKey
}
_, err := db.DB.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status, idempotency_key)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'pending', $7)
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON), idemArg)
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status, idempotency_key)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending', $6)
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), idemArg)
if err == nil {
// RFC #2829 #318 — mirror to the durable delegations ledger
// (gated by DELEGATION_LEDGER_WRITE; default off → no-op).
@@ -550,15 +544,10 @@ func (h *DelegationHandler) Record(c *gin.Context) {
"task": body.Task,
"delegation_id": body.DelegationID,
})
// Store delegation_id in response_body so agent check_delegation_status
// can locate this row. Fixes mc#984.
respJSON, _ := json.Marshal(map[string]interface{}{
"delegation_id": body.DelegationID,
})
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'dispatched')
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON)); err != nil {
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'dispatched')
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON)); err != nil {
log.Printf("Delegation Record: insert failed for %s: %v", body.DelegationID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record delegation"})
return
@@ -1,224 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
// extractResponseText tests — walks A2A JSON-RPC response bodies and
// returns the first text part, falling back to raw body on parse failures.
func TestExtractResponseText_PartsWithTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": "hello world"},
map[string]interface{}{"kind": "text", "text": "second part"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "hello world", extractResponseText(body))
}
func TestExtractResponseText_PartNotTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "image", "data": "base64..."},
map[string]interface{}{"kind": "text", "text": "visible"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "visible", extractResponseText(body))
}
func TestExtractResponseText_PartsEmpty(t *testing.T) {
// Empty parts array — falls through to artifacts, then raw body
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{},
},
}
body, _ := json.Marshal(resp)
// Falls through to raw body (which is the JSON string)
result := extractResponseText(body)
assert.NotEmpty(t, result)
}
func TestExtractResponseText_ArtifactPartsWithText(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"kind": "file",
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": "artifact text"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "artifact text", extractResponseText(body))
}
func TestExtractResponseText_ArtifactPartNotTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"kind": "code",
"parts": []interface{}{
map[string]interface{}{"kind": "image", "data": "..."},
map[string]interface{}{"kind": "text", "text": "code comment"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "code comment", extractResponseText(body))
}
func TestExtractResponseText_ArtifactsEmpty(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{},
},
}
body, _ := json.Marshal(resp)
result := extractResponseText(body)
// Falls back to raw body
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_NoResult(t *testing.T) {
// No "result" key at all — falls back to raw body
body := []byte(`{"error": {"code": -32600, "message": "Invalid Request"}}`)
result := extractResponseText(body)
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_ResultNotMap(t *testing.T) {
// result is a string, not a map — falls back to raw body
body := []byte(`{"result": "just a string"}`)
result := extractResponseText(body)
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_NonJSONBody(t *testing.T) {
// Non-JSON bytes — returns the raw string
body := []byte("plain text response, not JSON at all")
result := extractResponseText(body)
assert.Equal(t, "plain text response, not JSON at all", result)
}
func TestExtractResponseText_PartWithNilText(t *testing.T) {
// Text field is nil — kind is "text" but text is nil, should skip
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": nil},
map[string]interface{}{"kind": "text", "text": "found"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "found", extractResponseText(body))
}
func TestExtractResponseText_ArtifactPartWithNilText(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": nil},
map[string]interface{}{"kind": "text", "text": "artifact-found"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "artifact-found", extractResponseText(body))
}
func TestExtractResponseText_PartsWithNonMapElement(t *testing.T) {
// parts contains a non-map element — should be skipped gracefully
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
"not a map",
123,
nil,
map[string]interface{}{"kind": "text", "text": "parsed"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "parsed", extractResponseText(body))
}
func TestExtractResponseText_ArtifactWithNonMapElement(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
"not a map",
nil,
map[string]interface{}{
"parts": []interface{}{
"not a map",
map[string]interface{}{"kind": "text", "text": "safe"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "safe", extractResponseText(body))
}
func TestExtractResponseText_PartKindNotString(t *testing.T) {
// kind is an integer, not a string — should be skipped
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": 123, "text": "ignored"},
map[string]interface{}{"kind": "text", "text": "found"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "found", extractResponseText(body))
}
func TestExtractResponseText_EmptyResponse(t *testing.T) {
body := []byte("{}")
result := extractResponseText(body)
// Falls back to raw "{}"
assert.Equal(t, "{}", result)
}
func TestExtractResponseText_NilBody(t *testing.T) {
// nil byte slice — string(nil) = ""
result := extractResponseText(nil)
assert.Equal(t, "", result)
}
func TestExtractResponseText_WhitespaceBody(t *testing.T) {
body := []byte(" \n\t ")
result := extractResponseText(body)
// Unmarshals to empty map, no result, returns raw string
assert.Equal(t, " \n\t ", result)
}
@@ -1,447 +0,0 @@
package handlers
// delegation_list_test.go — unit tests for listDelegationsFromLedger and
// listDelegationsFromActivityLogs. Both methods are the data-backend of the
// ListDelegations handler; coverage was missing (cf. infra-sre review of PR #942).
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
)
// ---------- listDelegationsFromLedger ----------
func TestListDelegationsFromLedger_EmptyResult(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
})
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if got != nil {
t.Errorf("empty result: expected nil, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_SingleRow(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
// Use time.Time{} for nullable *time.Time columns — sqlmock passes the
// zero value to the handler's scan destination. The handler checks Valid
// before using each nullable field, so zero values are safe.
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
}).AddRow(
"del-1", "ws-1", "ws-2", "summarise the report",
"completed", "the report is about Q1",
"", now, now, now, now,
)
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if e["delegation_id"] != "del-1" {
t.Errorf("delegation_id: got %v, want del-1", e["delegation_id"])
}
if e["source_id"] != "ws-1" {
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
}
if e["target_id"] != "ws-2" {
t.Errorf("target_id: got %v, want ws-2", e["target_id"])
}
if e["status"] != "completed" {
t.Errorf("status: got %v, want completed", e["status"])
}
if e["response_preview"] != "the report is about Q1" {
t.Errorf("response_preview: got %v", e["response_preview"])
}
if _, ok := e["error"]; ok {
t.Errorf("error should be absent when empty, got %v", e["error"])
}
if e["_ledger"] != true {
t.Errorf("_ledger marker: got %v, want true", e["_ledger"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
}).
AddRow("del-a", "ws-1", "ws-2", "task a", "in_progress", "", "", now, now, now, now).
AddRow("del-b", "ws-1", "ws-3", "task b", "failed", "", "timeout", now, now, now, now).
AddRow("del-c", "ws-1", "ws-4", "task c", "completed", "result c", "", now, now, now, now)
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if len(got) != 3 {
t.Fatalf("expected 3 entries, got %d", len(got))
}
if got[0]["delegation_id"] != "del-a" || got[1]["delegation_id"] != "del-b" || got[2]["delegation_id"] != "del-c" {
t.Errorf("unexpected order: %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_QueryError(t *testing.T) {
// Query failure returns nil — graceful fallback, no panic.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnError(context.DeadlineExceeded)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if got != nil {
t.Errorf("query error: expected nil, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_RowsErr(t *testing.T) {
// rows.Err() mid-stream: handler collects partial results and returns them.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
// on first call — the row never scans, result stays nil. To get partial
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
// rows.Err() is error, handler returns partial result.
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
}).
AddRow("del-1", "ws-1", "ws-2", "task", "queued", "", "", now, now, now, now).
AddRow("del-2", "ws-1", "ws-3", "another task", "queued", "", "", now, now, now, now).
RowError(1, context.DeadlineExceeded)
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
// Handler logs the error but returns result (partial results because result != nil).
if got == nil || len(got) != 1 {
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromLedger_ScanError is removed.
//
// In Go 1.25 sqlmock.NewRows validates column count at AddRow() time and
// panics when len(values) != len(columns). The old pattern
// sqlmock.NewRows([]string{}).AddRow("only-one-col")
// therefore panics in test SETUP, not inside the handler. The handler has no
// recover(), so a scan panic would propagate out of listDelegationsFromLedger
// and crash the process — this is the correct behaviour (not silently skipping
// a row). The correct way to cover this path is a real-DB integration test.
//
// ---------- listDelegationsFromActivityLogs ----------
func TestListDelegationsFromActivityLogs_EmptyResult(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
})
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 0 {
t.Errorf("empty result: expected empty slice, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-1", "delegate",
"ws-1", "ws-2",
"analyse Q1 numbers",
"in_progress",
"", "", "",
now,
)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if e["id"] != "act-1" {
t.Errorf("id: got %v, want act-1", e["id"])
}
if e["type"] != "delegate" {
t.Errorf("type: got %v, want delegate", e["type"])
}
if e["source_id"] != "ws-1" {
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
}
if e["target_id"] != "ws-2" {
t.Errorf("target_id: got %v, want ws-2", e["target_id"])
}
if e["summary"] != "analyse Q1 numbers" {
t.Errorf("summary: got %v", e["summary"])
}
if e["status"] != "in_progress" {
t.Errorf("status: got %v", e["status"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-2", "delegate_result",
"ws-1", "ws-2",
"result summary",
"failed",
"Callee workspace not reachable",
`{"text":"the result body text"}`,
"del-abc",
now,
)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if e["type"] != "delegate_result" {
t.Errorf("type: got %v", e["type"])
}
if e["error"] != "Callee workspace not reachable" {
t.Errorf("error: got %v", e["error"])
}
if e["response_preview"] != `{"text":"the result body text"}` {
t.Errorf("response_preview: got %v", e["response_preview"])
}
if e["delegation_id"] != "del-abc" {
t.Errorf("delegation_id: got %v", e["delegation_id"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromActivityLogs_QueryError(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnError(context.DeadlineExceeded)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
// Error → returns empty slice, not nil.
if len(got) != 0 {
t.Errorf("query error: expected empty slice, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
// on first call — the row never scans, result stays nil. To get partial
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
// rows.Err() is error, handler returns partial result.
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now).
AddRow("act-2", "delegate", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
RowError(1, context.DeadlineExceeded)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
// Handler logs the error but returns result (partial results because result != nil).
if got == nil || len(got) != 1 {
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_ScanErrorSkipped is removed.
//
// Same reason as TestListDelegationsFromLedger_ScanError: Go 1.25 causes
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
// has no recover(), so a scan panic would crash the process — the correct
// behaviour. Real-DB integration tests cover this path.
@@ -133,9 +133,9 @@ func TestDelegate_Success(t *testing.T) {
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
// Expect INSERT into activity_logs for delegation tracking
// (6th arg is response_body, 7th is idempotency_key — nil here since the request omits it)
// (6th arg is idempotency_key — nil here since the request omits it)
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
WillReturnResult(sqlmock.NewResult(0, 1))
// Expect RecordAndBroadcast INSERT into structure_events
@@ -189,9 +189,9 @@ func TestDelegate_DBInsertFails_Still202WithWarning(t *testing.T) {
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
// DB insert fails (6th arg = response_body, 7th = idempotency_key, nil for this test)
// DB insert fails (6th arg = idempotency_key, nil for this test)
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
WillReturnError(fmt.Errorf("database connection lost"))
// RecordAndBroadcast still fires
@@ -491,7 +491,6 @@ func TestDelegationRecord_InsertsActivityLogRow(t *testing.T) {
"550e8400-e29b-41d4-a716-446655440001", // target_id
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
sqlmock.AnyArg(), // request_body (jsonb)
sqlmock.AnyArg(), // response_body (jsonb) — mc#984 fix
).
WillReturnResult(sqlmock.NewResult(0, 1))
// RecordAndBroadcast INSERT for DELEGATION_SENT
@@ -700,9 +699,9 @@ func TestDelegate_IdempotentFailedRowIsReleasedAndReplaced(t *testing.T) {
mock.ExpectExec("DELETE FROM activity_logs").
WithArgs("ws-source", "retry-key").
WillReturnResult(sqlmock.NewResult(0, 1))
// Fresh insert with the same idempotency key (response_body added as mc#984 fix).
// Fresh insert with the same idempotency key.
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "retry-key").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "retry-key").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -746,9 +745,9 @@ func TestDelegate_IdempotentRaceUniqueViolationReturnsExisting(t *testing.T) {
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
WithArgs("ws-source", "race-key").
WillReturnError(fmt.Errorf("sql: no rows in result set"))
// Insert loses the race against a concurrent caller (response_body added as mc#984 fix).
// Insert loses the race against a concurrent caller.
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "race-key").
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "race-key").
WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint \"activity_logs_idempotency_uniq\""))
// Re-query returns the winner.
mock.ExpectQuery("SELECT request_body->>'delegation_id', status").
@@ -1,160 +0,0 @@
package handlers
import (
"testing"
)
// filterPeersByQuery tests — nil-safe role/name filtering for peer discovery.
func TestFilterPeersByQuery_EmptyQueryNoOp(t *testing.T) {
peers := []map[string]interface{}{
{"name": "foo", "role": "bar"},
{"name": "baz", "role": "qux"},
}
result := filterPeersByQuery(peers, "")
if len(result) != 2 {
t.Errorf("empty query: expected 2, got %d", len(result))
}
}
func TestFilterPeersByQuery_WhitespaceQueryNoOp(t *testing.T) {
peers := []map[string]interface{}{
{"name": "foo", "role": "bar"},
}
result := filterPeersByQuery(peers, " ")
if len(result) != 1 {
t.Errorf("whitespace-only query: expected 1, got %d", len(result))
}
}
func TestFilterPeersByQuery_MatchName(t *testing.T) {
peers := []map[string]interface{}{
{"name": "backend-agent", "role": "sre"},
{"name": "frontend-agent", "role": "ui"},
}
result := filterPeersByQuery(peers, "backend")
if len(result) != 1 || result[0]["name"] != "backend-agent" {
t.Errorf("expected backend-agent, got %v", result)
}
}
func TestFilterPeersByQuery_MatchRole(t *testing.T) {
peers := []map[string]interface{}{
{"name": "agent-alpha", "role": "security engineer"},
{"name": "agent-beta", "role": "devops"},
}
result := filterPeersByQuery(peers, "engineer")
if len(result) != 1 || result[0]["name"] != "agent-alpha" {
t.Errorf("expected agent-alpha, got %v", result)
}
}
func TestFilterPeersByQuery_CaseInsensitive(t *testing.T) {
peers := []map[string]interface{}{
{"name": "AgentX", "role": "SRE"},
}
result := filterPeersByQuery(peers, "AGENTx")
if len(result) != 1 {
t.Errorf("expected 1 match (case-insensitive), got %d", len(result))
}
}
func TestFilterPeersByQuery_NilRoleNoPanic(t *testing.T) {
// This is the regression case for #730: queryPeerMaps explicitly sets
// peer["role"] = nil when the DB role is empty string. Before the fix,
// p["role"].(string) panics on nil. After the fix, it returns "" and
// no match occurs — which is the correct behaviour.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": "some-agent", "role": nil},
}
result := filterPeersByQuery(peers, "some-agent")
if len(result) != 1 {
t.Errorf("expected 1 match by name, got %d", len(result))
}
}
func TestFilterPeersByQuery_NilRoleQueryNoMatch(t *testing.T) {
// When role is nil and query does not match name, nothing matches.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": "agent-alpha", "role": nil},
}
result := filterPeersByQuery(peers, "no-match")
if len(result) != 0 {
t.Errorf("expected 0 matches, got %d", len(result))
}
}
func TestFilterPeersByQuery_NilNameNoPanic(t *testing.T) {
// Defensive check: name could also theoretically be nil.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil name: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": nil, "role": "sre"},
}
result := filterPeersByQuery(peers, "sre")
if len(result) != 1 {
t.Errorf("expected 1 match by role, got %d", len(result))
}
}
func TestFilterPeersByQuery_BothNilNoPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil name+role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": nil, "role": nil},
}
result := filterPeersByQuery(peers, "")
if len(result) != 1 {
t.Errorf("empty query with nil name/role: expected 1, got %d", len(result))
}
result = filterPeersByQuery(peers, "anything")
if len(result) != 0 {
t.Errorf("non-empty query with nil name/role: expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_NoMatches(t *testing.T) {
peers := []map[string]interface{}{
{"name": "alpha", "role": "beta"},
{"name": "gamma", "role": "delta"},
}
result := filterPeersByQuery(peers, "zzz")
if len(result) != 0 {
t.Errorf("expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_EmptyPeers(t *testing.T) {
result := filterPeersByQuery([]map[string]interface{}{}, "query")
if len(result) != 0 {
t.Errorf("empty peers: expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_MultipleMatches(t *testing.T) {
peers := []map[string]interface{}{
{"name": "backend-alpha", "role": "eng"},
{"name": "backend-beta", "role": "eng"},
{"name": "frontend", "role": "ui"},
}
result := filterPeersByQuery(peers, "backend")
if len(result) != 2 {
t.Errorf("expected 2 backend matches, got %d", len(result))
}
}
@@ -230,21 +230,20 @@ func TestWorkspaceList_WithData(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
// 21 cols — see scanWorkspaceRow for order (max_concurrent_tasks
// lands between active_tasks and last_error_rate).
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
"parent_id", "active_tasks", "max_concurrent_tasks",
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0)).
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -35,9 +35,8 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
t.Cleanup(func() { mockDB.Close() })
// Disable SSRF checks for the duration of this test only. Restore
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
@@ -367,7 +366,7 @@ func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
"ws-123",
"/tmp/configs/template",
map[string][]byte{"config.yaml": []byte("name: test")},
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
map[string]string{"OPENAI_API_KEY": "sk-test"},
"/tmp/plugins",
"workspace:ws-123",
@@ -392,21 +391,21 @@ func TestWorkspaceList(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
// 21 cols: `max_concurrent_tasks` added between active_tasks and
// last_error_rate (see scanWorkspaceRow + COALESCE(w.max_concurrent_tasks, 1)
// in workspace.go). Column order must match that scan exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
"parent_id", "active_tasks", "max_concurrent_tasks",
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0)).
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -1120,14 +1119,13 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("dddddddd-0004-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
nil, int64(0), false, true,
nil, int64(0),
))
w := httptest.NewRecorder()
@@ -248,9 +248,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
b.WriteString(content)
b.WriteString("\n\n")
}
if err := rows.Err(); err != nil {
log.Printf("ResolveInstructions rows.Err workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, gin.H{
"workspace_id": workspaceID,
@@ -261,7 +258,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
func scanInstructions(rows interface {
Next() bool
Scan(dest ...interface{}) error
Err() error
}) []Instruction {
var instructions []Instruction
for rows.Next() {
@@ -273,9 +269,6 @@ func scanInstructions(rows interface {
}
instructions = append(instructions, inst)
}
if err := rows.Err(); err != nil {
log.Printf("scanInstructions rows.Err: %v", err)
}
if instructions == nil {
instructions = []Instruction{}
}
@@ -1,884 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// ─── request helpers ───────────────────────────────────────────────────────────
func newPostRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
raw, _ := json.Marshal(body)
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
func newPutRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
raw, _ := json.Marshal(body)
c.Request = httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
func newDeleteRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodDelete, path, nil)
return w, c
}
func newGetRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
return w, c
}
// ─── mock row helpers ─────────────────────────────────────────────────────────
// instructionCols matches the SELECT in List/Resolve.
var instructionCols = []string{
"id", "scope", "scope_target", "title", "content",
"priority", "enabled", "created_at", "updated_at",
}
// resolveCols matches the SELECT in Resolve (scope, title, content).
var resolveCols = []string{"scope", "title", "content"}
// ─── List ────────────────────────────────────────────────────────────────────
func TestInstructionsList_ByWorkspaceID(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-123-abc"
w, c := newGetRequest("/instructions?workspace_id=" + wsID)
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?workspace_id="+wsID, nil)
rows := sqlmock.NewRows(instructionCols).
AddRow("inst-1", "global", nil, "Be helpful", "Always be helpful.", 10, true, time.Now(), time.Now()).
AddRow("inst-2", "workspace", &wsID, "Use Claude", "Use Claude Code.", 5, true, time.Now(), time.Now())
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
WithArgs(wsID).
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(out) != 2 {
t.Errorf("expected 2 instructions, got %d", len(out))
}
if out[0].Scope != "global" {
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_ByScope(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions?scope=global")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
rows := sqlmock.NewRows(instructionCols).
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WithArgs("global").
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(out) != 1 || out[0].Scope != "global" {
t.Errorf("unexpected response: %v", out)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_AllNoParams(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions")
rows := sqlmock.NewRows(instructionCols)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// Empty slice, not nil
if out == nil {
t.Error("expected empty slice, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnError(errors.New("connection refused"))
h.List(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Create ───────────────────────────────────────────────────────────────────
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Be Helpful",
"content": "Always be helpful to the user.",
"priority": 10,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var out map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if out["id"] != "new-inst-1" {
t.Errorf("expected id new-inst-1, got %s", out["id"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsCreate_ValidWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsTarget := "ws-xyz-789"
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"scope_target": wsTarget,
"title": "Use Claude Code",
"content": "Prefer Claude Code for all tasks.",
"priority": 5,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("workspace", &wsTarget, "Use Claude Code", "Prefer Claude Code for all tasks.", 5).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-inst-2"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsCreate_MissingScope(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"title": "Missing Scope",
"content": "This has no scope.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_MissingTitle(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"content": "Has no title.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_MissingContent(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Has no content",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_InvalidScope(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "team",
"title": "Bad Scope",
"content": "Team scope is not supported yet.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"title": "Missing Target",
"content": "Workspace scope without scope_target.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
// Build a string longer than maxInstructionContentLen (8192).
longContent := string(make([]byte, maxInstructionContentLen+1))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Too Long",
"content": longContent,
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
longTitle := string(make([]byte, 201))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": longTitle,
"content": "Short content.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "DB Error",
"content": "This will fail.",
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WillReturnError(errors.New("connection refused"))
h.Create(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Update ──────────────────────────────────────────────────────────────────
func TestInstructionsUpdate_ValidPartial(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-update-1"
newTitle := "Updated Title"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": newTitle,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, &newTitle, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_AllFields(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-update-2"
title := "Full Update"
content := "New content body."
priority := 20
enabled := false
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": title,
"content": content,
"priority": priority,
"enabled": enabled,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, &title, &content, &priority, &enabled).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-too-long"
longContent := string(make([]byte, maxInstructionContentLen+1))
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"content": longContent,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
h.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-title-long"
longTitle := string(make([]byte, 201))
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": longTitle,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
h.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsUpdate_NotFound(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-missing"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": "New Title",
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WillReturnResult(sqlmock.NewResult(0, 0))
h.Update(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-db-err"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": "Error Update",
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WillReturnError(errors.New("connection refused"))
h.Update(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Delete ───────────────────────────────────────────────────────────────────
func TestInstructionsDelete_Valid(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-delete-1"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Delete(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsDelete_NotFound(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-not-there"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnResult(sqlmock.NewResult(0, 0))
h.Delete(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsDelete_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-del-err"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnError(errors.New("connection refused"))
h.Delete(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Resolve ──────────────────────────────────────────────────────────────────
func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-resolve-1"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Be Helpful", "Always help the user.").
AddRow("global", "Stay on Topic", "Don't diverge.").
AddRow("workspace", "Use Claude Code", "Claude Code is the default runtime.")
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
WorkspaceID string `json:"workspace_id"`
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if out.WorkspaceID != wsID {
t.Errorf("expected workspace_id %s, got %s", wsID, out.WorkspaceID)
}
// Global section must come before workspace section.
if !bytes.Contains([]byte(out.Instructions), []byte("Platform-Wide Rules")) {
t.Error("instructions should contain 'Platform-Wide Rules' section")
}
if !bytes.Contains([]byte(out.Instructions), []byte("Role-Specific Rules")) {
t.Error("instructions should contain 'Role-Specific Rules' section")
}
// Global instructions must appear before workspace instructions.
idxGlobal := bytes.Index([]byte(out.Instructions), []byte("Platform-Wide Rules"))
idxWorkspace := bytes.Index([]byte(out.Instructions), []byte("Role-Specific Rules"))
if idxGlobal >= idxWorkspace {
t.Error("global section should appear before workspace section")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_EmptyWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-empty"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols)
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// No rows → builder writes nothing; empty string returned.
if out.Instructions != "" {
t.Errorf("expected empty instructions for empty workspace, got: %q", out.Instructions)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-err"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnError(errors.New("connection refused"))
h.Resolve(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/workspaces//instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: ""}}
h.Resolve(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ─── scanInstructions edge cases ───────────────────────────────────────────────
// NOTE: TestScanInstructions_ScanError was removed — go-sqlmock v1.5.2 does not
// implement Go 1.25's sql.Rows.Next([]byte) bool method, so *sqlmock.Rows cannot
// satisfy scanInstructions' interface. The test needs a sqlmock upgrade or a
// different mocking strategy (tracked: internal issue).
// ─── maxInstructionContentLen boundary ────────────────────────────────────────
func TestInstructionsCreate_ContentExactlyAtLimit(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
exactContent := string(make([]byte, maxInstructionContentLen))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "At Limit",
"content": exactContent,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "At Limit", exactContent, 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("at-limit-1"))
h.Create(c)
// Exactly at limit must succeed (8192 chars is acceptable).
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for content at limit, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── priority defaults ────────────────────────────────────────────────────────
func TestInstructionsCreate_PriorityDefaultsToZero(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
// Body omits priority — expect it defaults to 0.
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "No Priority",
"content": "Default priority body.",
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "No Priority", "Default priority body.", 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("no-prio-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── nil scope_target for global instructions ─────────────────────────────────
func TestInstructionsCreate_GlobalScopeNilTarget(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Global Nil Target",
"content": "Global instruction.",
})
// For global scope, scope_target must be SQL NULL.
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "Global Nil Target", "Global instruction.", 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("global-nil-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── workspace scope with empty string target (rejected) ─────────────────────
func TestInstructionsCreate_WorkspaceScopeEmptyStringTarget(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
empty := ""
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"scope_target": empty,
"title": "Empty Target",
"content": "Empty workspace target.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty string scope_target, got %d: %s", w.Code, w.Body.String())
}
}
// ─── Resolve: scope label transitions ────────────────────────────────────────
func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-only-global"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Rule One", "First rule.").
AddRow("global", "Rule Two", "Second rule.")
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// Two global instructions share one section header.
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-empty-update"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
c.Params = []gin.Param{{Key: "id", Value: instID}}
// COALESCE(nil, ...) = unchanged; still updates updated_at.
// Args order: ($1=id, $2=title, $3=content, $4=priority, $5=enabled)
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
@@ -751,9 +751,9 @@ func TestMCPHandler_SendMessageToUser_DBErrorLogsAndStill200s(t *testing.T) {
t.Setenv("MOLECULE_MCP_ALLOW_SEND_MESSAGE", "true")
h, mock := newMCPHandler(t)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-err").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
// INSERT fails — must NOT abort the tool response.
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
@@ -802,9 +802,9 @@ func TestMCPHandler_SendMessageToUser_ResponseBodyShape(t *testing.T) {
const userMessage = "Hi there from the agent"
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-shape").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
// Capture the response_body argument and assert its exact shape.
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
@@ -861,9 +861,9 @@ func TestMCPHandler_SendMessageToUser_PersistsToActivityLog(t *testing.T) {
// before it does anything else. Returning a name lets the
// broadcast payload populate; the test doesn't assert on the
// broadcast (no observable WS in this fake), only on the DB.
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-msg").
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
// The persistence INSERT — pin the exact shape so a future
// refactor that switches columns or drops `method='notify'`
-56
View File
@@ -271,62 +271,6 @@ func (e EnvRequirement) IsSatisfied(configured map[string]struct{}) bool {
return false
}
// perWorkspaceUnsatisfied records a single unsatisfied RequiredEnv for a
// specific workspace during org import preflight.
type perWorkspaceUnsatisfied struct {
Workspace string
FilesDir string
Unsatisfied EnvRequirement
}
// collectPerWorkspaceUnsatisfied walks the workspace tree and returns every
// RequiredEnv that is neither in `configured` (global secrets) nor resolvable
// from the org root or workspace-level .env file. An empty orgBaseDir skips
// the .env walk so all requirements appear unsatisfied (used by tests to
// isolate the global-only path).
func collectPerWorkspaceUnsatisfied(
workspaces []OrgWorkspace,
orgBaseDir string,
configured map[string]struct{},
) []perWorkspaceUnsatisfied {
var result []perWorkspaceUnsatisfied
for _, ws := range workspaces {
result = append(result, checkWorkspaceRequiredEnv(ws, orgBaseDir, configured)...)
}
return result
}
func checkWorkspaceRequiredEnv(
ws OrgWorkspace,
orgBaseDir string,
configured map[string]struct{},
) []perWorkspaceUnsatisfied {
var result []perWorkspaceUnsatisfied
// Merge in .env vars from the org root and the workspace-specific dir.
// Workspace-level vars override org-root vars, just as loadWorkspaceEnv
// implements: org root first, then ws dir on top.
if orgBaseDir != "" {
wsEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
for k, v := range wsEnv {
configured[k] = struct{}{}
_ = v // value only used for merging into configured map
}
}
for _, req := range ws.RequiredEnv {
if !req.IsSatisfied(configured) {
result = append(result, perWorkspaceUnsatisfied{
Workspace: ws.Name,
FilesDir: ws.FilesDir,
Unsatisfied: req,
})
}
}
for _, child := range ws.Children {
result = append(result, checkWorkspaceRequiredEnv(child, orgBaseDir, configured)...)
}
return result
}
// UnmarshalYAML accepts either a scalar (string → single) or a map
// with an `any_of` list (→ group).
func (e *EnvRequirement) UnmarshalYAML(value *yaml.Node) error {
@@ -64,9 +64,7 @@ func resolvePromptRef(inline, fileRef, orgBaseDir, filesDir string) (string, err
// envVarRefPattern matches actual ${VAR} or $VAR references (not literal $).
// Used to detect unresolved placeholders without false positives like "$5".
// Requires [a-zA-Z_] as the first char after $ so $100 stays literal.
// Two capture groups: (1) ${VAR} form, (2) $VAR form.
var envVarRefPattern = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`)
var envVarRefPattern = regexp.MustCompile(`\$\{?[A-Za-z_][A-Za-z0-9_]*\}?`)
// hasUnresolvedVarRef returns true if the original string had a ${VAR} or $VAR
// reference that the expanded string didn't fully replace (i.e. the var was unset).
@@ -80,103 +78,17 @@ func hasUnresolvedVarRef(original, expanded string) bool {
}
// expandWithEnv expands ${VAR} and $VAR references in s using the env map.
// Falls back to the platform process env only when the whole value is a
// single variable reference; embedded process-env expansion is too broad for
// imported org YAML because host variables such as HOME are not template data.
// Falls back to the platform process env if a var isn't in the map.
func expandWithEnv(s string, env map[string]string) string {
if s == "" {
return ""
}
var b strings.Builder
for i := 0; i < len(s); {
if s[i] != '$' {
b.WriteByte(s[i])
i++
continue
return os.Expand(s, func(key string) string {
if v, ok := env[key]; ok {
return v
}
if i+1 >= len(s) {
b.WriteByte('$')
i++
continue
}
if s[i+1] == '{' {
end := strings.IndexByte(s[i+2:], '}')
if end < 0 {
b.WriteByte('$')
i++
continue
}
end += i + 2
key := s[i+2 : end]
ref := s[i : end+1]
b.WriteString(expandEnvRef(key, ref, s, env))
i = end + 1
continue
}
if !isEnvIdentStart(s[i+1]) {
b.WriteByte('$')
i++
continue
}
j := i + 2
for j < len(s) && isEnvIdentPart(s[j]) {
j++
}
key := s[i+1 : j]
ref := s[i:j]
b.WriteString(expandEnvRef(key, ref, s, env))
i = j
}
return b.String()
}
// expandEnvRef resolves a single variable reference extracted from s.
//
// Guards:
// - Empty key → "$$" escape, return "$"
// - key[0] not POSIX ident start → "$" + partial chars, return "$<chars>"
// - Key in env map → return the mapped value (template override wins)
// - Otherwise → only fall back to os.Getenv if the whole input string IS the
// variable reference (ref == whole).
//
// Bare $VAR format:
// $HOME (alone) → ref==whole → os.Getenv ✓ (host HOME is org-template HOME)
// $HOME/path (partial) → ref!=whole → literal "$HOME" ✓ (CWE-78: prevents host leak)
//
// Braced ${VAR} format:
// ${HOME} (alone) → ref==whole → os.Getenv ✓
// ${ROLE}/admin (partial) → ref!=whole → literal ✓
// "yes and ${NOT_SET}" (embedded) → ref!=whole → literal ✓
//
// This is the CWE-78 fix from commit a3a358f9.
func expandEnvRef(key, ref, whole string, env map[string]string) string {
if key == "" {
return "$"
}
if !isEnvIdentStart(key[0]) {
return "$" + key
}
if v, ok := env[key]; ok {
return v
}
if ref == whole {
return os.Getenv(key)
}
return ref
})
}
func isEnvIdentStart(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
func isEnvIdentPart(c byte) bool {
return isEnvIdentStart(c) || (c >= '0' && c <= '9')
}
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env .env and the workspace-specific .env
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
@@ -428,11 +340,7 @@ func resolveInsideRoot(root, userPath string) (string, error) {
return "", fmt.Errorf("root abs: %w", err)
}
joined := filepath.Join(absRoot, userPath)
// filepath.Join preserves "." components when root is absolute; clean
// them before computing the final absolute path so "./subdir/./file.txt"
// resolves to root/subdir/file.txt (not root/./subdir/./file.txt).
cleaned := filepath.Clean(joined)
absJoined, err := filepath.Abs(cleaned)
absJoined, err := filepath.Abs(joined)
if err != nil {
return "", fmt.Errorf("joined abs: %w", err)
}
@@ -1,126 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupOrgEnv creates a temp dir with an optional org .env file and returns the dir.
func setupOrgEnv(t *testing.T, orgEnvContent string) string {
t.Helper()
dir := t.TempDir()
if orgEnvContent != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(orgEnvContent), 0o600))
}
return dir
}
func Test_loadWorkspaceEnv_orgRootOnly(t *testing.T) {
org := setupOrgEnv(t, "ORG_VAR=orgval\nORG_DEBUG=true")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "orgval", vars["ORG_VAR"])
assert.Equal(t, "true", vars["ORG_DEBUG"])
}
func Test_loadWorkspaceEnv_orgRootMissing(t *testing.T) {
// No .env at org root — should return empty map without error.
dir := t.TempDir()
vars := loadWorkspaceEnv(dir, "")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_workspaceEnvMerges(t *testing.T) {
org := setupOrgEnv(t, "SHARED=sharedval\nORG_ONLY=orgonly")
wsDir := filepath.Join(org, "myworkspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_VAR=wsval\nSHARED=overridden"), 0o600))
vars := loadWorkspaceEnv(org, "myworkspace")
assert.Equal(t, "wsval", vars["WS_VAR"])
assert.Equal(t, "overridden", vars["SHARED"]) // workspace overrides org
assert.Equal(t, "orgonly", vars["ORG_ONLY"]) // org vars preserved
}
func Test_loadWorkspaceEnv_emptyFilesDir(t *testing.T) {
org := setupOrgEnv(t, "VAR=val")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "val", vars["VAR"])
}
func Test_loadWorkspaceEnv_traversalRejects(t *testing.T) {
// #321 / CWE-22: filesDir "../../../etc" must not escape the org root.
// resolveInsideRoot rejects the traversal so workspace .env is skipped;
// org root .env is still loaded (it's before the guard).
org := setupOrgEnv(t, "INNOCENT=val\nSAFE_WS=wsval")
parent := filepath.Dir(org)
require.NoError(t, os.WriteFile(filepath.Join(parent, ".env"), []byte("MALICIOUS=evil"), 0o600))
// Also create a workspace dir inside org to prove it IS accessible normally.
wsDir := filepath.Join(org, "legit-workspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_SECRET=ssh-key-123"), 0o600))
// Traversal is blocked.
vars := loadWorkspaceEnv(org, "../../../etc")
// Org root vars present; workspace vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Equal(t, "wsval", vars["SAFE_WS"]) // from org root .env
assert.Empty(t, vars["WS_SECRET"]) // workspace .env blocked by traversal guard
_, hasEvil := vars["MALICIOUS"]
assert.False(t, hasEvil, "MALICIOUS from escaped path must not appear")
}
func Test_loadWorkspaceEnv_traversalWithDots(t *testing.T) {
// A sibling-traversal attempt: go up one level then into a sibling dir.
// The sibling dir is NOT inside org, so it must be rejected.
org := setupOrgEnv(t, "INNOCENT=val")
parent := filepath.Dir(org)
require.NoError(t, os.MkdirAll(filepath.Join(parent, "sibling"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(parent, "sibling/.env"), []byte("LEAKED=secret"), 0o600))
vars := loadWorkspaceEnv(org, "../sibling")
// Org vars loaded; sibling vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Empty(t, vars["LEAKED"], "sibling traversal must be rejected")
}
func Test_loadWorkspaceEnv_absolutePathRejected(t *testing.T) {
// Absolute paths are rejected outright by resolveInsideRoot.
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, "/etc")
assert.Equal(t, "val", vars["INNOCENT"]) // org root still loaded
assert.Empty(t, vars["SAFE_WS"])
}
func Test_loadWorkspaceEnv_dotPathRejected(t *testing.T) {
// "." resolves to the org root itself — this is NOT a traversal but
// would create org-root/.env which is the org root .env, not a
// workspace .env. resolveInsideRoot accepts this; the workspace .env
// path is org/.env, which IS the org root .env (already loaded).
// So the correct result is the org vars (same as org root, no change).
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, ".")
// "." passes resolveInsideRoot (resolves to org root, which is valid).
// But workspace path org/.env is the same as org/.env already loaded.
assert.Equal(t, "val", vars["INNOCENT"])
}
func Test_loadWorkspaceEnv_emptyOrgRootReturnsEmpty(t *testing.T) {
vars := loadWorkspaceEnv("", "some/dir")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_missingWorkspaceDir(t *testing.T) {
org := setupOrgEnv(t, "ORG=val")
// Workspace dir doesn't exist — org vars still loaded.
vars := loadWorkspaceEnv(org, "nonexistent")
assert.Equal(t, "val", vars["ORG"])
}
func assertEmpty(t *testing.T, m map[string]string) {
t.Helper()
assert.Equal(t, 0, len(m), "expected empty map, got %v", m)
}
@@ -1,723 +0,0 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
)
// ── isSafeRoleName ────────────────────────────────────────────────────────────
func TestIsSafeRoleName_Valid(t *testing.T) {
cases := []string{
"backend",
"frontend",
"backend-engineer",
"Frontend_Engineer",
"DevOps123",
"sre-team",
"a",
"ABC",
"Role_With_Underscores_And-Numbers123",
}
for _, r := range cases {
t.Run(r, func(t *testing.T) {
if !isSafeRoleName(r) {
t.Errorf("isSafeRoleName(%q): expected true, got false", r)
}
})
}
}
func TestIsSafeRoleName_Invalid(t *testing.T) {
cases := []struct {
name string
role string
}{
{"empty", ""},
{"dot", "."},
{"double dot", ".."},
{"path separator", "backend/engineer"},
{"space", "backend engineer"},
{"special char", "backend@engineer"},
{"at sign", "role@team"},
{"colon", "role:admin"},
{"hash", "role#1"},
{"percent", "role%20"},
{"quote", `role"name`},
{"backslash", `role\name`},
{"tilde", "role~test"},
{"backtick", "`role"},
{"bracket open", "[role]"},
{"bracket close", "role]"},
{"plus", "role+admin"},
{"equals", "role=admin"},
{"caret", "role^admin"},
{"question mark", "role?"},
{"pipe at end", "role|"},
{"greater than", "role>"},
{"asterisk", "role*"},
{"ampersand", "role&"},
{"exclamation at end", "role!"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if isSafeRoleName(tc.role) {
t.Errorf("isSafeRoleName(%q): expected false, got true", tc.role)
}
})
}
}
// ── hasUnresolvedVarRef ───────────────────────────────────────────────────────
func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
cases := []string{
"",
"plain text",
"no variables here",
"123 numeric",
"$",
"${}",
"$5",
"$$$$",
}
for _, s := range cases {
t.Run(s, func(t *testing.T) {
if hasUnresolvedVarRef(s, s) {
t.Errorf("hasUnresolvedVarRef(%q, %q): expected false, got true", s, s)
}
})
}
}
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
// Expansion consumed the var refs (where "consumed" means the output no longer
// contains the original var reference syntax).
cases := []struct {
orig string
expanded string
want bool // true = unresolved (function returns true), false = resolved
}{
// 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) {
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)
}
})
}
}
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
// Expansion left the refs intact → unresolved.
cases := []struct {
orig string
expanded string
}{
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"prefix${VAR}suffix", "prefix${VAR}suffix"},
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
}
for _, tc := range cases {
t.Run(tc.orig, func(t *testing.T) {
if !hasUnresolvedVarRef(tc.orig, tc.expanded) {
t.Errorf("hasUnresolvedVarRef(%q, %q): expected true, got false", tc.orig, tc.expanded)
}
})
}
}
// ── expandWithEnv ─────────────────────────────────────────────────────────────
func TestExpandWithEnv_Basic(t *testing.T) {
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
cases := []struct {
input string
want string
}{
{"", ""},
{"no vars", "no vars"},
{"${FOO}", "bar"},
{"$FOO", "bar"},
{"prefix${FOO}suffix", "prefixbarsuffix"},
{"${FOO}${BAZ}", "barqux"},
{"${MISSING}", ""}, // not in env, not in os env → empty
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := expandWithEnv(tc.input, env)
if got != tc.want {
t.Errorf("expandWithEnv(%q, %v) = %q, want %q", tc.input, env, got, tc.want)
}
})
}
}
// ── mergeCategoryRouting ─────────────────────────────────────────────────────
func TestMergeCategoryRouting_EmptyInputs(t *testing.T) {
// Both empty → empty
r := mergeCategoryRouting(nil, nil)
if len(r) != 0 {
t.Errorf("mergeCategoryRouting(nil, nil): got %v, want empty", r)
}
r = mergeCategoryRouting(map[string][]string{}, map[string][]string{})
if len(r) != 0 {
t.Errorf("mergeCategoryRouting({}, {}): got %v, want empty", r)
}
}
func TestMergeCategoryRouting_DefaultsOnly(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
"ui": {"Frontend Engineer"},
"data": {"Data Engineer"},
}
r := mergeCategoryRouting(defaults, nil)
if len(r) != 3 {
t.Errorf("got %d keys, want 3", len(r))
}
if len(r["security"]) != 2 {
t.Errorf("security roles: got %v, want 2", r["security"])
}
}
func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
"ui": {"Frontend Engineer"},
}
ws := map[string][]string{
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"infra": {"Platform Team"}, // adds
}
r := mergeCategoryRouting(defaults, ws)
if len(r["security"]) != 1 || r["security"][0] != "SRE Team" {
t.Errorf("security: got %v, want [SRE Team]", r["security"])
}
if _, ok := r["ui"]; ok {
t.Errorf("ui should be dropped, got %v", r["ui"])
}
if len(r["infra"]) != 1 || r["infra"][0] != "Platform Team" {
t.Errorf("infra: got %v, want [Platform Team]", r["infra"])
}
}
func TestMergeCategoryRouting_EmptyListDrops(t *testing.T) {
defaults := map[string][]string{"foo": {"A", "B"}}
ws := map[string][]string{"foo": {}}
r := mergeCategoryRouting(defaults, ws)
if _, ok := r["foo"]; ok {
t.Errorf("foo with empty ws list: should be dropped, got %v", r["foo"])
}
}
func TestMergeCategoryRouting_EmptyKeySkipped(t *testing.T) {
defaults := map[string][]string{"": {"Role"}}
ws := map[string][]string{"": {}}
r := mergeCategoryRouting(defaults, ws)
if _, ok := r[""]; ok {
t.Errorf("empty key should be skipped, got %v", r[""])
}
}
// ── renderCategoryRoutingYAML ────────────────────────────────────────────────
func TestRenderCategoryRoutingYAML_Empty(t *testing.T) {
out, err := renderCategoryRoutingYAML(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "" {
t.Errorf("got %q, want empty string", out)
}
out, err = renderCategoryRoutingYAML(map[string][]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "" {
t.Errorf("got %q, want empty string", out)
}
}
func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
// Keys are sorted so output is deterministic regardless of map iteration order.
m := map[string][]string{
"zebra": {"A"},
"alpha": {"B"},
"middle": {"C"},
}
out, err := renderCategoryRoutingYAML(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// alpha must come before middle, which must come before zebra
ai := 0
zi := 0
mi := 0
for i, c := range out {
switch {
case c == 'a' && i < len(out)-5 && out[i:i+5] == "alpha":
ai = i
case c == 'z' && i < len(out)-5 && out[i:i+5] == "zebra":
zi = i
case c == 'm' && i < len(out)-6 && out[i:i+6] == "middle":
mi = i
}
}
if ai <= 0 || zi <= 0 || mi <= 0 {
t.Fatalf("could not locate all keys in output: %s", out)
}
if ai >= mi || mi >= zi {
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
}
}
func TestRenderCategoryRoutingYAML_SpecialCharsEscaped(t *testing.T) {
// YAML library should escape characters that need quoting.
m := map[string][]string{
"key:with:colons": {"Role: Admin"},
"key with space": {"Role"},
}
out, err := renderCategoryRoutingYAML(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The output must be valid YAML (yaml.Marshal handles quoting).
// The key with colons should appear quoted in the output.
if out == "" {
t.Error("output is empty")
}
}
// ── appendYAMLBlock ───────────────────────────────────────────────────────────
func TestAppendYAMLBlock_NoExisting(t *testing.T) {
got := appendYAMLBlock(nil, "key: value")
if string(got) != "key: value" {
t.Errorf("got %q, want 'key: value'", string(got))
}
}
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"), "")
want := "existing: data\n"
if string(got) != want {
t.Errorf("got %q, want %q", string(got), want)
}
}
func TestAppendYAMLBlock_AppendsWithNewline(t *testing.T) {
existing := []byte("key: value")
block := "new: entry"
got := appendYAMLBlock(existing, block)
want := "key: value\nnew: entry"
if string(got) != want {
t.Errorf("got %q, want %q", string(got), want)
}
}
func TestAppendYAMLBlock_AlreadyEndsWithNewline(t *testing.T) {
existing := []byte("key: value\n")
block := "new: entry"
got := appendYAMLBlock(existing, block)
want := "key: value\nnew: entry"
if string(got) != want {
t.Errorf("got %q, want %q", string(got), want)
}
}
// ── mergePlugins ─────────────────────────────────────────────────────────────
func TestMergePlugins_EmptyInputs(t *testing.T) {
r := mergePlugins(nil, nil)
if len(r) != 0 {
t.Errorf("got %v, want []", r)
}
r = mergePlugins([]string{}, []string{})
if len(r) != 0 {
t.Errorf("got %v, want []", r)
}
}
func TestMergePlugins_BasicMerge(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"plugin-b", "plugin-c"}
r := mergePlugins(defaults, ws)
// defaults first, ws appended, b deduplicated
if len(r) != 3 {
t.Errorf("got %v, want 3 items", r)
}
if r[0] != "plugin-a" || r[1] != "plugin-b" || r[2] != "plugin-c" {
t.Errorf("got %v, want [a, b, c]", r)
}
}
func TestMergePlugins_ExcludeWithBang(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
ws := []string{"!plugin-b"}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
if r[0] != "plugin-a" || r[1] != "plugin-c" {
t.Errorf("got %v, want [a, c]", r)
}
}
func TestMergePlugins_ExcludeWithDash(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
ws := []string{"-plugin-b"}
r := mergePlugins(defaults, ws)
if len(r) != 2 || r[0] != "plugin-a" || r[1] != "plugin-c" {
t.Errorf("got %v, want [a, c]", r)
}
}
func TestMergePlugins_ExcludeNonexistent(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"!plugin-c"} // c not present
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
func TestMergePlugins_ExcludeEmptyTarget(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"!"}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
func TestMergePlugins_EmptyPlugin(t *testing.T) {
defaults := []string{"", "plugin-a", ""}
ws := []string{"plugin-b", ""}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
// ── Additional coverage: expandWithEnv ──────────────────────────────
func TestExpandWithEnv_BracedVar(t *testing.T) {
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
result := expandWithEnv("value is ${FOO}", env)
assert.Equal(t, "value is bar", result)
}
func TestExpandWithEnv_DollarVar(t *testing.T) {
env := map[string]string{"X": "1", "Y": "2"}
result := expandWithEnv("$X + $Y = 3", env)
assert.Equal(t, "1 + 2 = 3", result)
}
func TestExpandWithEnv_Mixed(t *testing.T) {
env := map[string]string{"A": "alpha", "B": "beta"}
result := expandWithEnv("${A}_${B}", env)
assert.Equal(t, "alpha_beta", result)
}
func TestExpandWithEnv_MissingVar(t *testing.T) {
// Missing vars stay as-is (os.Getenv fallback returns "" for unset vars).
env := map[string]string{}
result := expandWithEnv("${UNSET}", env)
assert.Equal(t, "", result)
}
func TestExpandWithEnv_EmptyMap(t *testing.T) {
result := expandWithEnv("no vars here", map[string]string{})
assert.Equal(t, "no vars here", result)
}
func TestExpandWithEnv_LiteralDollar(t *testing.T) {
// A bare $ not followed by a valid identifier char stays as-is.
result := expandWithEnv("cost $100", map[string]string{})
assert.Equal(t, "cost $100", result)
}
func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
env := map[string]string{"SET": "yes"}
result := expandWithEnv("${SET} and ${NOT_SET}", env)
// ${SET} resolved from env; ${NOT_SET} stays literal (not whole-string ref,
// so os.Getenv fallback is NOT used — CWE-78 regression guard).
assert.Equal(t, "yes and ${NOT_SET}", result)
}
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
// ── Additional coverage: mergeCategoryRouting ──────────────────────
func TestMergeCategoryRouting_WorkspaceAddsCategory(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer"},
}
wsRouting := map[string][]string{
"ui": {"Frontend Engineer"},
}
result := mergeCategoryRouting(defaults, wsRouting)
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
assert.Equal(t, []string{"Frontend Engineer"}, result["ui"])
}
func TestMergeCategoryRouting_EmptyListDropsCategory(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer"},
"infra": {"SRE"},
}
wsRouting := map[string][]string{
"security": {}, // empty list = explicit drop
}
result := mergeCategoryRouting(defaults, wsRouting)
_, hasSecurity := result["security"]
assert.False(t, hasSecurity)
assert.Equal(t, []string{"SRE"}, result["infra"])
}
func TestMergeCategoryRouting_EmptyDefaultKeySkipped(t *testing.T) {
defaults := map[string][]string{
"": {"Backend Engineer"}, // empty key should be skipped
}
result := mergeCategoryRouting(defaults, nil)
_, has := result[""]
assert.False(t, has)
}
func TestMergeCategoryRouting_EmptyWorkspaceKeySkipped(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer"},
}
wsRouting := map[string][]string{
"": {"Some Role"},
}
result := mergeCategoryRouting(defaults, wsRouting)
_, has := result[""]
assert.False(t, has)
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
}
func TestMergeCategoryRouting_DoesNotMutateInputs(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer"},
}
wsRouting := map[string][]string{
"security": {"DevOps"},
}
orig := defaults["security"][0]
_ = mergeCategoryRouting(defaults, wsRouting)
assert.Equal(t, orig, defaults["security"][0])
}
// renderCategoryRoutingYAML tests — deterministic YAML emission.
// ── Additional coverage: renderCategoryRoutingYAML ────────────────
func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
routing := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
}
result, err := renderCategoryRoutingYAML(routing)
assert.NoError(t, err)
assert.Contains(t, result, "security:")
assert.Contains(t, result, "Backend Engineer")
assert.Contains(t, result, "DevOps")
}
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
routing := map[string][]string{
"zebra": {"RoleZ"},
"alpha": {"RoleA"},
"middleware": {"RoleM"},
}
result, err := renderCategoryRoutingYAML(routing)
assert.NoError(t, err)
// Keys are sorted alphabetically.
idxAlpha := assertFind(t, result, "alpha:")
idxZebra := assertFind(t, result, "zebra:")
idxMid := assertFind(t, result, "middleware:")
if idxAlpha > -1 && idxZebra > -1 {
assert.True(t, idxAlpha < idxZebra, "alpha should appear before zebra")
}
if idxMid > -1 && idxZebra > -1 {
assert.True(t, idxMid < idxZebra, "middleware should appear before zebra")
}
}
func TestRenderCategoryRoutingYAML_EmptyListCategory(t *testing.T) {
// Empty-list category should still render (mergeCategoryRouting drops
// them before they reach this function, but we test the render in isolation).
routing := map[string][]string{
"security": {},
}
result, err := renderCategoryRoutingYAML(routing)
assert.NoError(t, err)
assert.Contains(t, result, "security:")
}
func TestRenderCategoryRoutingYAML_SpecialCharactersEscaped(t *testing.T) {
routing := map[string][]string{
"notes": {`has: colon`, `and "quotes"`, "emoji: 🚀"},
}
result, err := renderCategoryRoutingYAML(routing)
assert.NoError(t, err)
// Should not panic and should produce valid YAML.
assert.Contains(t, result, "notes:")
}
// appendYAMLBlock tests — safe concatenation with newline boundary.
// ── Additional coverage: appendYAMLBlock ───────────────────────────
func TestAppendYAMLBlock_BothEmpty(t *testing.T) {
result := appendYAMLBlock(nil, "")
assert.Nil(t, result) // append(nil, []byte("")...) returns nil in Go
}
func TestAppendYAMLBlock_ExistingHasNewline(t *testing.T) {
existing := []byte("existing:\n")
block := "key: value\n"
result := appendYAMLBlock(existing, block)
assert.Equal(t, "existing:\nkey: value\n", string(result))
}
func TestAppendYAMLBlock_ExistingNoNewline(t *testing.T) {
existing := []byte("existing:")
block := "key: value\n"
result := appendYAMLBlock(existing, block)
assert.Equal(t, "existing:\nkey: value\n", string(result))
}
func TestAppendYAMLBlock_ExistingEmpty(t *testing.T) {
existing := []byte("")
block := "key: value\n"
result := appendYAMLBlock(existing, block)
assert.Equal(t, "key: value\n", string(result))
}
func TestAppendYAMLBlock_NilExisting(t *testing.T) {
block := "key: value\n"
result := appendYAMLBlock(nil, block)
assert.Equal(t, "key: value\n", string(result))
}
// mergePlugins tests — union with exclusion prefix (!/-).
// ── Additional coverage: mergePlugins (additional cases) ───────────
func TestMergePlugins_DefaultsOnly(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
result := mergePlugins(defaults, nil)
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
}
func TestMergePlugins_WorkspaceAdds(t *testing.T) {
defaults := []string{"plugin-a"}
wsPlugins := []string{"plugin-b", "plugin-a"} // duplicate of default
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
}
func TestMergePlugins_ExclusionWithBang(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
wsPlugins := []string{"!plugin-b"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
}
func TestMergePlugins_ExclusionWithDash(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
wsPlugins := []string{"-plugin-b"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
}
func TestMergePlugins_ExclusionEmptyTarget(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
wsPlugins := []string{"!", "-"} // no-op exclusions
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
}
func TestMergePlugins_ExclusionNotInDefaults(t *testing.T) {
// Excluding something not in defaults is a no-op.
defaults := []string{"plugin-a"}
wsPlugins := []string{"!plugin-b"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a"}, result)
}
func TestMergePlugins_WorkspaceAddsNew(t *testing.T) {
defaults := []string{"plugin-a"}
wsPlugins := []string{"plugin-b"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
}
func TestMergePlugins_DeduplicationOrder(t *testing.T) {
// Defaults first; workspace entries deduplicated.
defaults := []string{"plugin-a", "plugin-a", "plugin-b"}
wsPlugins := []string{"plugin-b", "plugin-c", "plugin-c"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-a", "plugin-b", "plugin-c"}, result)
}
func TestMergePlugins_ExclusionThenAddSameName(t *testing.T) {
// Remove then re-add: order matters.
defaults := []string{"plugin-a", "plugin-b"}
wsPlugins := []string{"!plugin-a", "plugin-a"}
result := mergePlugins(defaults, wsPlugins)
assert.Equal(t, []string{"plugin-b", "plugin-a"}, result)
}
// isSafeRoleName tests — alphanumeric + hyphen/underscore, no path separators.
// ── Additional coverage: isSafeRoleName ───────────────────────────
func TestIsSafeRoleName_SpecialCharsRejected(t *testing.T) {
bad := []string{
"role@name",
"role#name",
"role$name",
"role%name",
"role&name",
"role*name",
"role?name",
"role=name",
}
for _, r := range bad {
if isSafeRoleName(r) {
t.Errorf("isSafeRoleName(%q) expected false, got true", r)
}
}
}
// assertFind is a helper: returns index of first occurrence of substr in s, or -1.
func assertFind(t *testing.T, s, substr string) int {
t.Helper()
idx := -1
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
idx = i
break
}
}
return idx
}
@@ -1,396 +0,0 @@
package handlers
import (
"path/filepath"
"strings"
"testing"
)
// org_helpers_security_test.go — security-critical path sanitization + role-name
// validation for org template processing. Covers OFFSEC-006-class attacks:
// path traversal via user-controlled files_dir / prompt_file refs, and role-name
// injection via the persona env loader.
// ── resolveInsideRoot ──────────────────────────────────────────────────────────
func TestResolveInsideRoot_EmptyUserPath(t *testing.T) {
_, err := resolveInsideRoot("/safe/root", "")
if err == nil {
t.Fatal("empty userPath: expected error, got nil")
}
if err.Error() != "path is empty" {
t.Errorf("empty userPath: got %q, want %q", err.Error(), "path is empty")
}
}
func TestResolveInsideRoot_AbsolutePathRejected(t *testing.T) {
_, err := resolveInsideRoot("/safe/root", "/etc/passwd")
if err == nil {
t.Fatal("absolute userPath: expected error, got nil")
}
if err.Error() != "absolute paths are not allowed" {
t.Errorf("absolute userPath: got %q, want %q", err.Error(), "absolute paths are not allowed")
}
}
func TestResolveInsideRoot_DotDotTraversal(t *testing.T) {
// ../../etc/passwd from /safe/root
got, err := resolveInsideRoot("/safe/root", "../../etc/passwd")
if err == nil {
t.Fatalf("dotdot traversal: expected error, got %q", got)
}
if err.Error() != "path escapes root" {
t.Errorf("dotdot traversal: got %q, want %q", err.Error(), "path escapes root")
}
}
func TestResolveInsideRoot_DotDotWithIntermediate(t *testing.T) {
// a/b/../../c normalises to "c" — a valid descendant inside any root.
// Must use t.TempDir() for a real filesystem path so filepath.Abs resolves.
root := t.TempDir()
got, err := resolveInsideRoot(root, "a/b/../../c")
if err != nil {
t.Fatalf("a/b/../../c should resolve within root: %v", err)
}
// Verify result is inside root and ends with "c"
if !strings.HasPrefix(got, root+string(filepath.Separator)) {
t.Errorf("result should be inside root %q, got %q", root, got)
}
if got[len(got)-1:] != "c" {
t.Errorf("resolved path should end in 'c', got %q", got)
}
}
func TestResolveInsideRoot_ValidRelativePath(t *testing.T) {
// This test uses the real filesystem since resolveInsideRoot calls filepath.Abs.
// Use t.TempDir() so we have a real root to work with.
root := t.TempDir()
got, err := resolveInsideRoot(root, "subdir/file.txt")
if err != nil {
t.Fatalf("valid relative: unexpected error: %v", err)
}
// Must be inside root
if got[:len(root)] != root {
t.Errorf("result should start with root %q, got %q", root, got)
}
}
func TestResolveInsideRoot_ExactRootMatch(t *testing.T) {
root := t.TempDir()
got, err := resolveInsideRoot(root, ".")
if err != nil {
t.Fatalf("exact root: unexpected error: %v", err)
}
if got != root {
t.Errorf("exact root match: got %q, want %q", got, root)
}
}
func TestResolveInsideRoot_DotPathComponent(t *testing.T) {
root := t.TempDir()
// ./subdir/./file.txt should resolve to root/subdir/file.txt
got, err := resolveInsideRoot(root, "./subdir/./file.txt")
if err != nil {
t.Fatalf("dot path component: unexpected error: %v", err)
}
if !strings.HasSuffix(got, "/subdir/file.txt") {
t.Errorf("dot path component: got %q, want suffix /subdir/file.txt", got)
}
}
func TestResolveInsideRoot_NestedDotDotEscapes(t *testing.T) {
root := t.TempDir()
// a/../../b from /tmp/dirsomething → /tmp/b (escapes temp dir)
got, err := resolveInsideRoot(root, "a/../../b")
if err == nil {
t.Fatalf("nested dotdot: expected error, got %q", got)
}
if err.Error() != "path escapes root" {
t.Errorf("nested dotdot: got %q, want %q", err.Error(), "path escapes root")
}
}
func TestResolveInsideRoot_DotdotAtStart(t *testing.T) {
root := t.TempDir()
got, err := resolveInsideRoot(root, "../sibling")
if err == nil {
t.Fatalf("../sibling: expected error, got %q", got)
}
if err.Error() != "path escapes root" {
t.Errorf("../sibling: got %q, want %q", err.Error(), "path escapes root")
}
}
func TestResolveInsideRoot_SiblingNotEscaped(t *testing.T) {
// /foo/bar and /foo/baz are siblings — the prefix check with
// filepath.Separator guard must allow /foo/bar/child without matching /foo/baz
// (which would be wrong if the check were just strings.HasPrefix).
root := t.TempDir()
got, err := resolveInsideRoot(root, "valid-subdir/file.txt")
if err != nil {
t.Fatalf("sibling not escaped: unexpected error: %v", err)
}
// Must be inside root
if !strings.HasPrefix(got, root+string(filepath.Separator)) {
t.Errorf("result should be inside root %q, got %q", root, got)
}
}
// ── isSafeRoleName ────────────────────────────────────────────────────────────
func TestIsSafeRoleName_Empty(t *testing.T) {
if isSafeRoleName("") {
t.Error("isSafeRoleName(\"\"): expected false, got true")
}
}
func TestIsSafeRoleName_Dot(t *testing.T) {
if isSafeRoleName(".") {
t.Error("isSafeRoleName(\".\"): expected false, got true")
}
}
func TestIsSafeRoleName_DotDot(t *testing.T) {
if isSafeRoleName("..") {
t.Error("isSafeRoleName(\"..\"): expected false, got true")
}
}
func TestIsSafeRoleName_PathTraversal(t *testing.T) {
unsafe := []string{
"../etc",
"foo/../../../etc",
"foo/../../bar",
}
for _, name := range unsafe {
if isSafeRoleName(name) {
t.Errorf("isSafeRoleName(%q): expected false (path traversal), got true", name)
}
}
}
func TestIsSafeRoleName_SpecialChars(t *testing.T) {
unsafe := []string{
"foo:bar",
"foo bar",
"foo\tbar",
"foo\nbar",
"foo\x00bar",
"foo@bar",
"foo#bar",
"foo$bar",
}
for _, name := range unsafe {
if isSafeRoleName(name) {
t.Errorf("isSafeRoleName(%q): expected false (special char), got true", name)
}
}
}
// ── mergeCategoryRouting ──────────────────────────────────────────────────────
func TestMergeCategoryRouting_BothNil(t *testing.T) {
got := mergeCategoryRouting(nil, nil)
if len(got) != 0 {
t.Errorf("both nil: got %v, want empty", got)
}
}
func TestMergeCategoryRouting_DefaultOnly(t *testing.T) {
defaultRouting := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
}
got := mergeCategoryRouting(defaultRouting, nil)
if len(got) != 1 {
t.Fatalf("default only: got %d entries, want 1", len(got))
}
if len(got["security"]) != 2 {
t.Errorf("security roles: got %v, want [Backend Engineer, DevOps]", got["security"])
}
}
func TestMergeCategoryRouting_WorkspaceOnly(t *testing.T) {
wsRouting := map[string][]string{
"ui": {"Frontend Engineer"},
}
got := mergeCategoryRouting(nil, wsRouting)
if len(got) != 1 {
t.Fatalf("ws only: got %d entries, want 1", len(got))
}
if got["ui"][0] != "Frontend Engineer" {
t.Errorf("ui roles: got %v, want [Frontend Engineer]", got["ui"])
}
}
func TestMergeCategoryRouting_MergeNoOverlap(t *testing.T) {
defaultRouting := map[string][]string{
"security": {"Backend Engineer"},
}
wsRouting := map[string][]string{
"ui": {"Frontend Engineer"},
}
got := mergeCategoryRouting(defaultRouting, wsRouting)
if len(got) != 2 {
t.Errorf("merge no overlap: got %d entries, want 2", len(got))
}
}
func TestMergeCategoryRouting_WsOverrideDropsDefault(t *testing.T) {
defaultRouting := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
}
wsRouting := map[string][]string{
"security": {"Security Engineer"},
}
got := mergeCategoryRouting(defaultRouting, wsRouting)
if len(got["security"]) != 1 {
t.Errorf("ws override: got %v, want [Security Engineer]", got["security"])
}
if got["security"][0] != "Security Engineer" {
t.Errorf("ws override: got %v, want [Security Engineer]", got["security"])
}
}
func TestMergeCategoryRouting_EmptyRolesInDefaultSkipped(t *testing.T) {
defaultRouting := map[string][]string{
"security": {},
}
got := mergeCategoryRouting(defaultRouting, nil)
if len(got) != 0 {
t.Errorf("empty roles in default should be skipped, got %v", got)
}
}
func TestMergeCategoryRouting_OriginalMapsUnmodified(t *testing.T) {
defaultRouting := map[string][]string{
"security": {"Backend Engineer"},
}
wsRouting := map[string][]string{
"ui": {"Frontend Engineer"},
}
mergeCategoryRouting(defaultRouting, wsRouting)
if len(defaultRouting) != 1 || len(defaultRouting["security"]) != 1 {
t.Error("default routing should be unmodified after merge")
}
if len(wsRouting) != 1 {
t.Error("ws routing should be unmodified after merge")
}
}
// ── expandWithEnv ─────────────────────────────────────────────────────────────
//
// CWE-78 regression tests. The original fix (a3a358f9) ensures that partial
// variable references like $HOME/path are NOT resolved via os.Getenv — the
// host HOME env var must not leak into org template values. Only whole-string
// references ($VAR or ${VAR}) may fall back to the host process environment.
func TestExpandWithEnv_PartialRefDollarHomePath(t *testing.T) {
// $HOME/path must NOT resolve to the host's HOME env var.
// The literal $HOME must be returned as-is.
got := expandWithEnv("$HOME/path", nil)
if got != "$HOME/path" {
t.Errorf("$HOME/path: got %q, want literal $HOME/path", got)
}
}
func TestExpandWithEnv_PartialRefBracedRoleAdmin(t *testing.T) {
// ${ROLE}/admin — ROLE is not in env, so expand to the literal ${ROLE}/admin.
got := expandWithEnv("${ROLE}/admin", nil)
if got != "${ROLE}/admin" {
t.Errorf("${ROLE}/admin: got %q, want literal ${ROLE}/admin", got)
}
}
func TestExpandWithEnv_PartialRefMiddleOfString(t *testing.T) {
// $ROLE in the middle of a string — literal, not os.Getenv.
got := expandWithEnv("prefix/$ROLE/suffix", nil)
if got != "prefix/$ROLE/suffix" {
t.Errorf("prefix/$ROLE/suffix: got %q, want literal", got)
}
}
func TestExpandWithEnv_WholeVarInEnv(t *testing.T) {
// Whole-string $VAR that IS in env — env value wins.
env := map[string]string{"FOO": "barvalue"}
got := expandWithEnv("$FOO", env)
if got != "barvalue" {
t.Errorf("$FOO with FOO=barvalue: got %q, want barvalue", got)
}
}
func TestExpandWithEnv_WholeVarBracedInEnv(t *testing.T) {
// Whole-string ${VAR} that IS in env — env value wins.
env := map[string]string{"FOO": "barvalue"}
got := expandWithEnv("${FOO}", env)
if got != "barvalue" {
t.Errorf("${FOO} with FOO=barvalue: got %q, want barvalue", got)
}
}
func TestExpandWithEnv_WholeVarNotInEnvBare(t *testing.T) {
// Whole-string $VAR not in env — falls back to os.Getenv.
// If the host has the var, we get the host value. If not, empty.
// At minimum, the result must NOT be the literal "$UNDEFINED_VAR_9Z".
got := expandWithEnv("$UNDEFINED_VAR_9Z", nil)
if got == "$UNDEFINED_VAR_9Z" {
t.Errorf("$UNDEFINED_VAR_9Z: should expand (whole-string fallback to os.Getenv), got literal")
}
}
func TestExpandWithEnv_WholeVarNotInEnvBraced(t *testing.T) {
// Whole-string ${VAR} not in env — falls back to os.Getenv.
got := expandWithEnv("${UNDEFINED_VAR_9Z}", nil)
if got == "${UNDEFINED_VAR_9Z}" {
t.Errorf("${UNDEFINED_VAR_9Z}: should expand (whole-string fallback to os.Getenv), got literal")
}
}
func TestExpandWithEnv_EmptyString(t *testing.T) {
got := expandWithEnv("", map[string]string{"FOO": "bar"})
if got != "" {
t.Errorf("empty string: got %q, want empty", got)
}
}
func TestExpandWithEnv_NoVarRefs(t *testing.T) {
got := expandWithEnv("plain string with no vars", map[string]string{"FOO": "bar"})
if got != "plain string with no vars" {
t.Errorf("plain string: got %q, want unchanged", got)
}
}
func TestExpandWithEnv_MultipleVarRefs(t *testing.T) {
// Two vars, both whole — both expand from env.
env := map[string]string{"A": "alpha", "B": "beta"}
got := expandWithEnv("$A and $B and more", env)
if got != "alpha and beta and more" {
t.Errorf("multiple vars: got %q, want alpha and beta and more", got)
}
}
func TestExpandWithEnv_NumericVarRef(t *testing.T) {
// $5 — starts with digit, not a valid identifier start.
// Must return the literal "$5", not expand via os.Getenv.
got := expandWithEnv("$5", map[string]string{"5": "five"})
if got != "$5" {
t.Errorf("$5: got %q, want literal $5", got)
}
}
func TestExpandWithEnv_DollarEscape(t *testing.T) {
// $$ → both $ written literally (each $ is not followed by an identifier char,
// so it is written as-is). No special escape sequence for $$.
got := expandWithEnv("$$", nil)
if got != "$$" {
t.Errorf("$$: got %q, want literal $$", got)
}
}
func TestExpandWithEnv_MixedPartialAndWhole(t *testing.T) {
// $A is in env (whole), $HOME is partial — only $A expands.
env := map[string]string{"A": "alpha"}
got := expandWithEnv("$A at $HOME", env)
if got != "alpha at $HOME" {
t.Errorf("$A at $HOME: got %q, want alpha at $HOME", got)
}
}
@@ -1,191 +0,0 @@
package handlers
import (
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
// walkOrgWorkspaceNames tests — recursive collection of non-empty workspace names.
func TestWalkOrgWorkspaceNames_EmptySlice(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{}, &names)
assert.Empty(t, names)
}
func TestWalkOrgWorkspaceNames_SingleNode(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{{Name: "my-workspace"}}, &names)
assert.Equal(t, []string{"my-workspace"}, names)
}
func TestWalkOrgWorkspaceNames_SingleNodeEmptyName(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{{Name: ""}}, &names)
assert.Empty(t, names)
}
func TestWalkOrgWorkspaceNames_NestedChildren(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{
Name: "parent",
Children: []OrgWorkspace{
{Name: "child-a"},
{Name: "child-b"},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"parent", "child-a", "child-b"}, names)
}
func TestWalkOrgWorkspaceNames_DeeplyNested(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{
Name: "level0",
Children: []OrgWorkspace{
{
Name: "level1",
Children: []OrgWorkspace{
{
Name: "level2",
Children: []OrgWorkspace{
{Name: "level3"},
},
},
},
},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"level0", "level1", "level2", "level3"}, names)
}
func TestWalkOrgWorkspaceNames_SkipsEmptyNames(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "a"},
{Name: ""},
{Name: "b"},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"a", "b"}, names)
}
func TestWalkOrgWorkspaceNames_Siblings(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "team"},
{Name: "alpha"},
{Name: "beta"},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"team", "alpha", "beta"}, names)
}
func TestWalkOrgWorkspaceNames_MultipleRoots(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "root-a", Children: []OrgWorkspace{{Name: "child-a"}}},
{Name: "root-b", Children: []OrgWorkspace{{Name: "child-b"}}},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"root-a", "child-a", "root-b", "child-b"}, names)
}
func TestWalkOrgWorkspaceNames_SpawningFalseStillWalks(t *testing.T) {
// The comment in the source is explicit: spawning:false subtrees are
// still walked. Empty names within those subtrees are still skipped.
var names []string
yes := true
no := false
tree := []OrgWorkspace{
{
Name: "parent",
Children: []OrgWorkspace{
{Name: "spawning-child", Spawning: &yes},
{Name: "non-spawning-child", Spawning: &no},
{Name: ""},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"parent", "spawning-child", "non-spawning-child"}, names)
}
// resolveProvisionConcurrency tests — env-var parsing with sensible fallback.
func TestResolveProvisionConcurrency_Default(t *testing.T) {
os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_ValidPositiveInt(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "5")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, 5, val)
}
func TestResolveProvisionConcurrency_ZeroUnlimited(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "0")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
// Zero is mapped to 1<<20 (unlimited semantics with finite cap)
assert.Equal(t, 1<<20, val)
}
func TestResolveProvisionConcurrency_NegativeFallsBack(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "-1")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_NonIntegerFallsBack(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "not-a-number")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_WhitespaceOnly(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", " ")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_LargeValue(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "10000")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, 10000, val)
}
// errString tests — nil-safe error-to-string wrapper.
func TestErrString_NilError(t *testing.T) {
result := errString(nil)
assert.Equal(t, "", result)
}
func TestErrString_WithError(t *testing.T) {
err := errors.New("something went wrong")
result := errString(err)
assert.Equal(t, "something went wrong", result)
}
func TestErrString_EmptyError(t *testing.T) {
err := errors.New("")
result := errString(err)
assert.Equal(t, "", result)
}
@@ -952,6 +952,54 @@ type PerWorkspaceUnsatisfied struct {
// collectPerWorkspaceUnsatisfied recursively walks workspaces and returns
// per-workspace RequiredEnv entries that are not covered by (a) a global
// secret key or (b) a key present in the workspace's .env file(s) (org root
// .env + per-workspace <files_dir>/.env). This complements
// collectOrgEnv + loadConfiguredGlobalSecretKeys, which together only
// validate global-level RequiredEnv against global_secrets. The .env
// lookup mirrors the runtime resolution in createWorkspaceTree so that
// the preflight result matches what the container actually receives at
// start time.
func collectPerWorkspaceUnsatisfied(workspaces []OrgWorkspace, orgBaseDir string, globalSecrets map[string]struct{}) []PerWorkspaceUnsatisfied {
var out []PerWorkspaceUnsatisfied
var walk func([]OrgWorkspace)
walk = func(wsList []OrgWorkspace) {
for _, ws := range wsList {
// Build the set of keys available to this workspace from .env.
// This is the same three-source stack that createWorkspaceTree
// injects into the container:
// 1. Org root .env (parseEnvFile, no filesDir)
// 2. Workspace <files_dir>/.env (if filesDir is set)
// 3. Persona bootstrap env (MOLECULE_PERSONA_ROOT/<filesDir>/env)
// Items 1+2 are on-disk and testable; item 3 is host-only and
// skipped here (persona env does NOT satisfy required_env —
// it carries identity tokens, not workspace LLM keys).
envFromFiles := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
// Convert map[string]string (from .env files) to map[string]struct{}
// to match IsSatisfied's signature.
envSet := make(map[string]struct{}, len(envFromFiles))
for k := range envFromFiles {
envSet[k] = struct{}{}
}
for _, req := range ws.RequiredEnv {
if req.IsSatisfied(globalSecrets) {
continue // covered by a global secret
}
if req.IsSatisfied(envSet) {
continue // covered by a per-workspace .env file
}
out = append(out, PerWorkspaceUnsatisfied{
Workspace: ws.Name,
FilesDir: ws.FilesDir,
Unsatisfied: req,
})
}
walk(ws.Children)
}
}
walk(workspaces)
return out
}
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
@@ -17,9 +17,6 @@ import (
// when one exists, or the workspace's own ID when it is the org root.
// Returns an empty string if the workspace is not found.
func resolveOrgID(ctx context.Context, workspaceID string) (string, error) {
if db.DB == nil {
return "", nil // nil in unit tests
}
var parentID sql.NullString
err := db.DB.QueryRowContext(ctx,
`SELECT parent_id FROM workspaces WHERE id = $1`,
+20 -70
View File
@@ -1,8 +1,6 @@
package handlers
import (
"errors"
"fmt"
"sort"
"strings"
"testing"
@@ -356,6 +354,12 @@ 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") {
@@ -363,6 +367,20 @@ func TestHasUnresolvedVarRef_LiteralDollar(t *testing.T) {
}
}
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", "") {
@@ -1058,71 +1076,3 @@ func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) {
t.Errorf("expected VALID_ONE to survive, got %v", reqNames(req))
}
}
func TestResolveProvisionConcurrency_ValidPositive(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "8")
got := resolveProvisionConcurrency()
if got != 8 {
t.Errorf("valid positive: got %d, want 8", got)
}
}
func TestResolveProvisionConcurrency_Zero(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "0")
got := resolveProvisionConcurrency()
if got != 1<<20 {
t.Errorf("zero (unlimited): got %d, want %d", got, 1<<20)
}
}
func TestResolveProvisionConcurrency_Negative(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "-5")
got := resolveProvisionConcurrency()
if got != defaultProvisionConcurrency {
t.Errorf("negative: got %d, want default %d", got, defaultProvisionConcurrency)
}
}
func TestResolveProvisionConcurrency_NonInteger(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "abc")
got := resolveProvisionConcurrency()
if got != defaultProvisionConcurrency {
t.Errorf("non-integer: got %d, want default %d", got, defaultProvisionConcurrency)
}
}
func TestResolveProvisionConcurrency_Whitespace(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", " 7 ")
got := resolveProvisionConcurrency()
if got != 7 {
t.Errorf("whitespace: got %d, want 7", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// errString tests
// ─────────────────────────────────────────────────────────────────────────────
func TestErrString_Nil(t *testing.T) {
got := errString(nil)
if got != "" {
t.Errorf("nil error: got %q, want empty string", got)
}
}
func TestErrString_NonNil(t *testing.T) {
err := fmt.Errorf("something went wrong")
got := errString(err)
if got != "something went wrong" {
t.Errorf("non-nil error: got %q, want %q", got, "something went wrong")
}
}
func TestErrString_Wrapped(t *testing.T) {
inner := errors.New("inner")
err := fmt.Errorf("outer: %w", inner)
got := errString(err)
if !strings.Contains(got, "outer") {
t.Errorf("wrapped error: got %q, want containing 'outer'", got)
}
}

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