Compare commits
55 Commits
pr-527
...
test/memorytab
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c54b59099 | |||
| 6ee9ecdf0d | |||
| c9166faac2 | |||
| 2ca0433a35 | |||
| e7965a0f0c | |||
| f6f477d6b3 | |||
| 83b4e4a88a | |||
| 98323734ea | |||
| 1f2089a6a9 | |||
| 4d2636f31a | |||
| 451cec1a75 | |||
| 8724776e24 | |||
| f6275dd6c0 | |||
| c74c0a0283 | |||
| a2a1e644ab | |||
| 05c794ef33 | |||
| 4db64bcbc3 | |||
| 9b10af08c9 | |||
| 6bf7df1f3f | |||
| caeff4bf80 | |||
| 210da3b1a5 | |||
| 57bf2eccc6 | |||
| e05fb6911d | |||
| 8a572c1ef3 | |||
| 3206966ee0 | |||
| 899972b1c1 | |||
| a50cce0590 | |||
| 49a4c3a736 | |||
| 0f63b7177a | |||
| 68f536bf4c | |||
| b0eb9fbb1d | |||
| 6e6abdd940 | |||
| afaf0a1e54 | |||
| 41bb9e48d9 | |||
| e09425ba81 | |||
| e8c78d6a20 | |||
| 8bd3585f55 | |||
| a507d5d19f | |||
| 7f90630f98 | |||
| 303cc4623e | |||
| 1688c1a991 | |||
| 3ba138d37e | |||
| 4b371918ec | |||
| ceddd060b0 | |||
| c8b06c1367 | |||
| 565898fe5a | |||
| 25ff821c4f | |||
| 6d06b30b79 | |||
| 6fa306a692 | |||
| c58aef31e7 | |||
| 451c2f554a | |||
| 5b2298e56f | |||
| 6916ae32c3 | |||
| 42fb4ed1c7 | |||
| 0c5eec5081 |
@@ -0,0 +1,673 @@
|
||||
#!/usr/bin/env python3
|
||||
"""status-reaper — Option B compensating-status POST for Gitea 1.22.6's
|
||||
hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
|
||||
Tracking: this PR (workflow + script + tests + audit issue). Sibling
|
||||
bots: internal#327 (publish-runtime-bot), internal#328 (mc-drift-bot).
|
||||
Upstream RFC: internal#80. Persona provisioned by sub-agent aefaac1b
|
||||
(2026-05-11 21:39Z; Gitea uid 94, scope=write:repository).
|
||||
|
||||
What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
|
||||
1. Walk `.gitea/workflows/*.yml`. For each file, build the workflow_id
|
||||
using this resolution (per hongming-pc 22:08Z review):
|
||||
- If YAML has top-level `name:` → use that.
|
||||
- Else → use filename stem (basename minus `.yml`).
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the SAME identifier (collision).
|
||||
- Any identifier containing `/` (it would break context parsing
|
||||
downstream — Gitea uses ` / ` as the workflow/job separator).
|
||||
Classify each by whether `on:` contains a `push:` trigger.
|
||||
|
||||
2. List the last N (=10) commits on WATCH_BRANCH via
|
||||
GET /repos/{o}/{r}/commits?sha={branch}&limit={N}. rev2 sweeps
|
||||
N commits per tick instead of HEAD only — schedule workflows
|
||||
post `failure` to whatever SHA was HEAD when they COMPLETED, so
|
||||
by the next */5 tick main has often moved forward and the red
|
||||
gets stranded on a stale commit (Phase 1+2 evidence: rev1 saw
|
||||
`compensated:0` every tick across ~6 cycles).
|
||||
|
||||
3. For EACH SHA in the list:
|
||||
- GET combined commit status. Per-SHA error isolation
|
||||
(refinement #7): if this call raises ApiError or any 5xx,
|
||||
LOG `::warning::` + continue to the next SHA. Different from
|
||||
the single-HEAD pre-rev2 path where fail-loud was correct;
|
||||
the sweep is best-effort across historical commits, so one
|
||||
transient blip on a stale SHA must not strand reds on the
|
||||
OTHER stale SHAs.
|
||||
- If combined.state == "success": skip — cost optimization
|
||||
(refinement #2), common case (most commits are green).
|
||||
- Otherwise iterate per-context entries. For each entry where:
|
||||
state == "failure" AND context.endswith(" (push)")
|
||||
Parse context as `<workflow_name> / <job_name> (push)`.
|
||||
Look up workflow_name in the trigger map:
|
||||
- missing → log ::notice:: and skip (conservative).
|
||||
- has_push_trigger=True → preserve (real defect signal).
|
||||
- has_push_trigger=False → POST a compensating
|
||||
`state=success` status to /statuses/{sha} with the same
|
||||
context (Gitea de-dups by context) and a description
|
||||
documenting the workaround + this script's path.
|
||||
|
||||
4. Exit 0. Re-running is idempotent — Gitea's commit-status table
|
||||
stores the LATEST state-per-context, so the success POST sticks
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch any context NOT ending in ` (push)`. The required-checks on
|
||||
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
|
||||
they CANNOT be reached by this code path.
|
||||
- Compensate `error`/`pending` states. Only `failure` — the only one
|
||||
Gitea emits for the hardcoded-suffix bug.
|
||||
- Write to non-default branches. WATCH_BRANCH is sourced from
|
||||
`github.event.repository.default_branch` in the workflow.
|
||||
- Mutate workflows or runs. The Actions UI still shows the
|
||||
underlying schedule-triggered run as failed; this script edits
|
||||
the commit-status surface only.
|
||||
|
||||
Halt conditions (script-level — orchestrator-level halts are in the
|
||||
workflow comments):
|
||||
- PyYAML missing → fail-loud at import (no fallback parse).
|
||||
- Workflow `name:` collision → exit 1 with ::error:: message.
|
||||
- Workflow `name:` containing `/` → exit 1 with ::error:: message.
|
||||
- Ambiguous `on:` shape (e.g. neither str/list/dict) → treat as
|
||||
"has_push_trigger=True" and log ::notice:: (preserve, never
|
||||
compensate the unknown).
|
||||
- api() non-2xx → raise ApiError, fail the workflow run loudly so
|
||||
a subsequent tick retries (per
|
||||
`feedback_api_helper_must_raise_not_return_dict`).
|
||||
|
||||
Local dry-run (no network):
|
||||
GITEA_TOKEN=... GITEA_HOST=git.moleculesai.app REPO=owner/repo \\
|
||||
WATCH_BRANCH=main WORKFLOWS_DIR=.gitea/workflows \\
|
||||
python3 .gitea/scripts/status-reaper.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Environment
|
||||
# --------------------------------------------------------------------------
|
||||
def _env(key: str, *, default: str = "") -> str:
|
||||
"""Read an env var with a default. Module-import-safe — tests can
|
||||
import this script without setting the full env contract."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
GITEA_TOKEN = _env("GITEA_TOKEN")
|
||||
GITEA_HOST = _env("GITEA_HOST")
|
||||
REPO = _env("REPO")
|
||||
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
|
||||
WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
"""Enforce env contract — called from `main()` only.
|
||||
|
||||
Tests import individual functions without setting the full env
|
||||
contract. Mirrors `main-red-watchdog.py`/`ci-required-drift.py`.
|
||||
"""
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "WORKFLOWS_DIR"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tiny HTTP helper — raises on non-2xx + on JSON-decode-of-expected-JSON.
|
||||
# --------------------------------------------------------------------------
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised when a Gitea API call cannot be trusted to have succeeded.
|
||||
|
||||
Per `feedback_api_helper_must_raise_not_return_dict`: soft-failure is
|
||||
opt-in via `expect_json=False`, never the default. A pre-fix
|
||||
implementation that returned `{}` on non-2xx would skip the
|
||||
compensating POST on a transient outage AND silently lose the
|
||||
failed-status enumeration, painting main green via omission.
|
||||
"""
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
"""Tiny HTTP helper around urllib. Same contract as
|
||||
`main-red-watchdog.py` and `ci-required-drift.py` so behaviour
|
||||
is cross-checkable."""
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
if expect_json:
|
||||
raise ApiError(
|
||||
f"{method} {path} -> HTTP {status} but body is not JSON: {e}"
|
||||
) from e
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Workflow scan + classification
|
||||
# --------------------------------------------------------------------------
|
||||
def _on_block(doc: dict) -> Any:
|
||||
"""Extract the `on:` block from a parsed YAML doc.
|
||||
|
||||
PyYAML parses bareword `on:` as Python `True` (YAML 1.1 boolean
|
||||
spec — `on/off/yes/no` are booleans). The actual key in the dict
|
||||
is therefore `True`, NOT the string `"on"`. We accept both for
|
||||
forward-compat with YAML 1.2 loaders (which keep it as `"on"`).
|
||||
"""
|
||||
if True in doc:
|
||||
return doc[True]
|
||||
return doc.get("on")
|
||||
|
||||
|
||||
def _has_push_trigger(on_block: Any, workflow_id: str) -> bool:
|
||||
"""Return True if `on:` block declares a `push` trigger.
|
||||
|
||||
Accepts the three common shapes:
|
||||
- str: `on: push` → True only if == "push"
|
||||
- list: `on: [push, pull_request]` → True if "push" in list
|
||||
- dict: `on: { push: {...}, schedule: ... }` → True if "push" key
|
||||
|
||||
Defensive: for anything else (including None/empty), return True
|
||||
so we preserve rather than over-compensate. Logged via ::notice::.
|
||||
"""
|
||||
if isinstance(on_block, str):
|
||||
return on_block == "push"
|
||||
if isinstance(on_block, list):
|
||||
return "push" in on_block
|
||||
if isinstance(on_block, dict):
|
||||
return "push" in on_block
|
||||
# None or unexpected shape — preserve, log.
|
||||
print(
|
||||
f"::notice::ambiguous on: for {workflow_id}; preserving "
|
||||
f"(value={on_block!r}, type={type(on_block).__name__})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def scan_workflows(workflows_dir: str) -> dict[str, bool]:
|
||||
"""Walk `workflows_dir` and return `{workflow_id: has_push_trigger}`.
|
||||
|
||||
Workflow ID resolution (per hongming-pc 22:08Z review):
|
||||
- Top-level `name:` if present.
|
||||
- Else filename stem (basename minus `.yml`).
|
||||
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the same ID (collision).
|
||||
- Any ID containing `/` (would break ` / `-separated context
|
||||
parsing on the downstream side).
|
||||
|
||||
Returns a dict for O(1) lookup in the per-status loop.
|
||||
"""
|
||||
path = Path(workflows_dir)
|
||||
if not path.is_dir():
|
||||
# Workflow dir missing → no workflows to classify. Empty map is
|
||||
# safe: per-status loop will hit "unknown workflow; skip" for
|
||||
# every entry, which is correct (we cannot tell if a push
|
||||
# trigger exists, so we preserve).
|
||||
print(f"::warning::workflows dir not found: {workflows_dir}")
|
||||
return {}
|
||||
|
||||
out: dict[str, bool] = {}
|
||||
sources: dict[str, str] = {} # workflow_id -> source file (for collision msg)
|
||||
|
||||
for yml in sorted(path.glob("*.yml")):
|
||||
try:
|
||||
with yml.open() as f:
|
||||
doc = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
# A malformed YAML in the workflows dir is a real defect
|
||||
# (the workflow wouldn't load on Gitea either). Surface it
|
||||
# and keep going — the reaper's job is to compensate the
|
||||
# OTHER workflows even if one is broken.
|
||||
print(f"::warning::yaml parse failed for {yml.name}: {e}; skip")
|
||||
continue
|
||||
if not isinstance(doc, dict):
|
||||
print(f"::warning::workflow {yml.name} not a dict; skip")
|
||||
continue
|
||||
|
||||
# Resolve workflow_id.
|
||||
name_field = doc.get("name")
|
||||
if isinstance(name_field, str) and name_field.strip():
|
||||
workflow_id = name_field.strip()
|
||||
else:
|
||||
workflow_id = yml.stem # basename minus .yml
|
||||
|
||||
# Halt-loud: `/` in workflow_id breaks ` / ` context parsing.
|
||||
if "/" in workflow_id:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name contains '/' which breaks "
|
||||
f"context parsing: {workflow_id} (file={yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Halt-loud: ID collision.
|
||||
if workflow_id in out:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name collision detected: {workflow_id} "
|
||||
f"(files: {sources[workflow_id]} + {yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
on_block = _on_block(doc)
|
||||
out[workflow_id] = _has_push_trigger(on_block, workflow_id)
|
||||
sources[workflow_id] = yml.name
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea reads
|
||||
# --------------------------------------------------------------------------
|
||||
def get_head_sha(branch: str) -> str:
|
||||
"""HEAD SHA of `branch`. Raises ApiError on non-2xx."""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"branch {branch} response not a JSON object")
|
||||
commit = body.get("commit")
|
||||
if not isinstance(commit, dict):
|
||||
raise ApiError(f"branch {branch} response missing `commit` object")
|
||||
sha = commit.get("id") or commit.get("sha")
|
||||
if not isinstance(sha, str) or len(sha) < 7:
|
||||
raise ApiError(f"branch {branch} response has no usable commit SHA")
|
||||
return sha
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
"""Combined commit status for `sha`. Gitea returns:
|
||||
{
|
||||
"state": "success" | "failure" | "pending" | "error",
|
||||
"statuses": [
|
||||
{"context": "...", "state": "...", "target_url": "...",
|
||||
"description": "..."},
|
||||
...
|
||||
],
|
||||
...
|
||||
}
|
||||
Raises ApiError on non-2xx.
|
||||
"""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"status for {sha} response not a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name).
|
||||
|
||||
Returns None if the context doesn't match the shape (caller skips).
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
return None
|
||||
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
|
||||
if " / " not in head:
|
||||
# No workflow/job separator — not the bug shape we compensate.
|
||||
return None
|
||||
workflow_name, job_name = head.split(" / ", 1)
|
||||
return workflow_name, job_name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
def post_compensating_status(
|
||||
sha: str,
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
given context. Gitea de-dups by context (latest write wins).
|
||||
|
||||
Description references this script so the compensation is
|
||||
self-documenting on the commit's status view.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
# Echo the original target_url when present so a human auditing
|
||||
# the (now-green) compensated status can still reach the run logs
|
||||
# that produced the original red.
|
||||
if target_url:
|
||||
payload["target_url"] = target_url
|
||||
|
||||
if dry_run:
|
||||
print(
|
||||
f"::notice::[dry-run] would compensate {context!r} on {sha[:10]} "
|
||||
f"with state=success"
|
||||
)
|
||||
return
|
||||
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/statuses/{sha}", body=payload)
|
||||
print(f"::notice::compensated {context!r} on {sha[:10]} (state=success)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main reap loop
|
||||
# --------------------------------------------------------------------------
|
||||
def reap(
|
||||
workflow_trigger_map: dict[str, bool],
|
||||
combined: dict,
|
||||
sha: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Walk `combined.statuses[]` and compensate where appropriate.
|
||||
|
||||
Per-SHA worker. The multi-SHA orchestrator (`reap_branch`) calls
|
||||
this once per stale main commit each tick.
|
||||
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable,
|
||||
compensated_contexts: [<context>, ...]}
|
||||
|
||||
`compensated_contexts` is rev2-added so `reap_branch` can build
|
||||
`compensated_per_sha` without re-deriving it from the POST stream.
|
||||
"""
|
||||
counters: dict[str, Any] = {
|
||||
"compensated": 0,
|
||||
"preserved_real_push": 0,
|
||||
"preserved_unknown": 0,
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_contexts": [],
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
context = s.get("context") or ""
|
||||
state = s.get("state") or ""
|
||||
|
||||
# Only `failure` is the bug shape. `error`/`pending`/`success`
|
||||
# left alone — they have other meanings.
|
||||
if state != "failure":
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Branch-protection required checks (e.g. `Secret scan / Scan
|
||||
# diff (pull_request)`) are NOT reachable from this path.
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
counters["preserved_non_push_suffix"] += 1
|
||||
continue
|
||||
|
||||
parsed = parse_push_context(context)
|
||||
if parsed is None:
|
||||
# Has ` (push)` suffix but missing ` / ` separator — not
|
||||
# the bug shape. Preserve.
|
||||
counters["preserved_unparseable"] += 1
|
||||
continue
|
||||
workflow_name, _job_name = parsed
|
||||
|
||||
if workflow_name not in workflow_trigger_map:
|
||||
# Real workflow but renamed/deleted/external — we can't
|
||||
# tell if it has push trigger. Conservative: preserve.
|
||||
print(f"::notice::unknown workflow {workflow_name!r}; skip")
|
||||
counters["preserved_unknown"] += 1
|
||||
continue
|
||||
|
||||
if workflow_trigger_map[workflow_name]:
|
||||
# Real push trigger → real defect signal. Preserve.
|
||||
counters["preserved_real_push"] += 1
|
||||
continue
|
||||
|
||||
# Class-O: schedule/dispatch/etc.-only workflow with a fake
|
||||
# (push) status from Gitea's hardcoded-suffix bug. Compensate.
|
||||
post_compensating_status(
|
||||
sha, context, s.get("target_url"), dry_run=dry_run
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
counters["compensated_contexts"].append(context)
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rev2: multi-SHA sweep over the last N commits on WATCH_BRANCH
|
||||
# --------------------------------------------------------------------------
|
||||
# How many main commits to sweep per tick. Sized to cover a burst-merge
|
||||
# window where multiple PRs land in the 5-min interval between reaper
|
||||
# ticks. Older reds falling off the window is acceptable — they were
|
||||
# already stale enough that the schedule-run that posted them has long
|
||||
# since been overwritten by a real push trigger. See `reference_post_
|
||||
# suspension_pipeline` for the merge-cadence baseline.
|
||||
DEFAULT_SWEEP_LIMIT = 10
|
||||
|
||||
|
||||
def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
|
||||
"""List the most recent `limit` commit SHAs on `branch`, newest
|
||||
first.
|
||||
|
||||
Wraps GET /repos/{o}/{r}/commits?sha={branch}&limit={limit}. Gitea
|
||||
1.22.6 returns a JSON list of commit objects each with a `sha` key
|
||||
(verified via vendor-truth probe 2026-05-11 against
|
||||
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
|
||||
|
||||
Raises ApiError on non-2xx OR on unexpected response shape. This is
|
||||
a HARD halt — without the commit list the sweep can't proceed. (The
|
||||
per-SHA error isolation downstream is a different concern: tolerating
|
||||
a transient 5xx on ONE commit's status is best-effort; losing the
|
||||
commit list itself means we don't even know which commits to try.)
|
||||
"""
|
||||
_, body = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits",
|
||||
query={"sha": branch, "limit": str(limit)},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(
|
||||
f"commits listing for {branch} not a JSON array "
|
||||
f"(got {type(body).__name__})"
|
||||
)
|
||||
shas: list[str] = []
|
||||
for entry in body:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
sha = entry.get("sha")
|
||||
if isinstance(sha, str) and len(sha) >= 7:
|
||||
shas.append(sha)
|
||||
if not shas:
|
||||
raise ApiError(
|
||||
f"commits listing for {branch} returned no usable SHAs"
|
||||
)
|
||||
return shas
|
||||
|
||||
|
||||
def reap_branch(
|
||||
workflow_trigger_map: dict[str, bool],
|
||||
branch: str,
|
||||
*,
|
||||
limit: int = DEFAULT_SWEEP_LIMIT,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Sweep the last `limit` commits on `branch`, applying `reap()`
|
||||
to each (with per-SHA error isolation).
|
||||
|
||||
Returns aggregated counters PLUS rev2 observability fields:
|
||||
- scanned_shas: how many SHAs we actually iterated
|
||||
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
|
||||
SHAs that actually got at least one compensation are included
|
||||
"""
|
||||
shas = list_recent_commit_shas(branch, limit)
|
||||
|
||||
aggregate: dict[str, Any] = {
|
||||
"scanned_shas": 0,
|
||||
"compensated": 0,
|
||||
"preserved_real_push": 0,
|
||||
"preserved_unknown": 0,
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_per_sha": {},
|
||||
}
|
||||
|
||||
for sha in shas:
|
||||
aggregate["scanned_shas"] += 1
|
||||
|
||||
# Per-SHA error isolation (refinement #7). One transient blip
|
||||
# on a historical commit must NOT abort the whole tick — the
|
||||
# OTHER stale SHAs may still hold strandable reds.
|
||||
try:
|
||||
combined = get_combined_status(sha)
|
||||
except ApiError as e:
|
||||
print(
|
||||
f"::warning::get_combined_status({sha[:10]}) failed; "
|
||||
f"skipping this SHA: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Cost optimization (refinement #2): the common case is a green
|
||||
# commit. Skip the per-context loop entirely when combined is
|
||||
# already success — saves a tight loop over ~20 statuses per SHA
|
||||
# on green commits, the dominant majority.
|
||||
if combined.get("state") == "success":
|
||||
continue
|
||||
|
||||
per_sha = reap(
|
||||
workflow_trigger_map, combined, sha, dry_run=dry_run
|
||||
)
|
||||
|
||||
# Aggregate scalar counters.
|
||||
for key in (
|
||||
"compensated",
|
||||
"preserved_real_push",
|
||||
"preserved_unknown",
|
||||
"preserved_non_failure",
|
||||
"preserved_non_push_suffix",
|
||||
"preserved_unparseable",
|
||||
):
|
||||
aggregate[key] += per_sha[key]
|
||||
|
||||
# Record per-SHA compensated contexts (only when non-empty —
|
||||
# keep the summary readable when most SHAs are no-ops).
|
||||
contexts = per_sha.get("compensated_contexts") or []
|
||||
if contexts:
|
||||
aggregate["compensated_per_sha"][sha] = list(contexts)
|
||||
|
||||
return aggregate
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Skip the compensating POST; print what would be done.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=DEFAULT_SWEEP_LIMIT,
|
||||
help=(
|
||||
"How many recent commits on WATCH_BRANCH to sweep per tick "
|
||||
f"(default: {DEFAULT_SWEEP_LIMIT})."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_require_runtime_env()
|
||||
|
||||
workflow_trigger_map = scan_workflows(WORKFLOWS_DIR)
|
||||
print(
|
||||
f"::notice::scanned {len(workflow_trigger_map)} workflows; "
|
||||
f"push-triggered={sum(1 for v in workflow_trigger_map.values() if v)}, "
|
||||
f"class-O candidates={sum(1 for v in workflow_trigger_map.values() if not v)}"
|
||||
)
|
||||
|
||||
counters = reap_branch(
|
||||
workflow_trigger_map,
|
||||
WATCH_BRANCH,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
# Observability: print one JSON line summarising the tick. Loki
|
||||
# ingestion via the runner's stdout (`source="gitea-actions"`).
|
||||
print(
|
||||
"status-reaper summary: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"branch": WATCH_BRANCH,
|
||||
"dry_run": args.dry_run,
|
||||
"limit": args.limit,
|
||||
**counters,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -317,7 +317,8 @@ JQ_FILTER='.[]
|
||||
|
||||
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
|
||||
|
||||
T12_CANDIDATES=$(echo "$T12_INPUT" | /tmp/jq -r "$JQ_FILTER" 2>/dev/null | sort -u)
|
||||
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
|
||||
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
|
||||
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
|
||||
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
|
||||
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
|
||||
|
||||
+33
-7
@@ -148,6 +148,21 @@ jobs:
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
echo "::endgroup::"
|
||||
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-pu.log
|
||||
echo "::endgroup::"
|
||||
continue-on-error: true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run tests with race detection and coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
@@ -493,10 +508,12 @@ jobs:
|
||||
# explicitly excludes `github.event_name`-gated jobs from F1 (see
|
||||
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
|
||||
#
|
||||
# NOTE: `continue-on-error: true` is intentionally NOT set here — Phase 3
|
||||
# (parent PR for ci.yml port, RFC §1) sets it on the underlying build
|
||||
# jobs to surface defects without blocking. The sentinel itself must
|
||||
# hard-fail; that's the whole point.
|
||||
# Phase 3 (RFC #219 §1) safety: continue-on-error here so the sentinel
|
||||
# does not hard-fail and block PRs while the underlying build jobs are
|
||||
# still in Phase 3 (continue-on-error: true suppresses their status to null).
|
||||
# When Phase 3 ends (defects fixed, continue-on-error flipped off on build
|
||||
# jobs), remove continue-on-error here so the sentinel again hard-fails.
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
needs:
|
||||
@@ -510,18 +527,27 @@ jobs:
|
||||
- name: Assert every required dependency succeeded
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `needs.*.result` is one of: success | failure | cancelled | skipped
|
||||
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
|
||||
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
|
||||
# Null results are skipped: they come from Phase 3 (continue-on-error: true
|
||||
# suppresses status) or from jobs still in-flight. The sentinel succeeds
|
||||
# rather than blocking PRs on Phase 3 noise.
|
||||
results='${{ toJSON(needs) }}'
|
||||
echo "$results"
|
||||
echo "$results" | python3 -c '
|
||||
import json, sys
|
||||
ns = json.load(sys.stdin)
|
||||
bad = [(k, v.get("result")) for k, v in ns.items() if v.get("result") != "success"]
|
||||
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
|
||||
bad = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") not in ("success", None)]
|
||||
if bad:
|
||||
print(f"FAIL: jobs not green:", file=sys.stderr)
|
||||
for k, r in bad:
|
||||
print(f" - {k}: {r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"OK: all {len(ns)} required jobs succeeded")
|
||||
pending = [(k, v.get("result")) for k, v in ns.items() if v.get("result") is None]
|
||||
if pending:
|
||||
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
|
||||
", ".join(k for k, _ in pending), file=sys.stderr)
|
||||
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
|
||||
'
|
||||
|
||||
@@ -71,8 +71,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch all open PRs and run gate-check on each
|
||||
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
|
||||
# gate_check.py uses timeout=15 on every urlopen call; this catches the
|
||||
# inline Python polling loop too (issue #603).
|
||||
pr_numbers=$(python3 -c "
|
||||
import urllib.request, json, os
|
||||
import socket, urllib.request, json, os
|
||||
socket.setdefaulttimeout(15)
|
||||
token = os.environ['GITEA_TOKEN']
|
||||
req = urllib.request.Request(
|
||||
'https://git.moleculesai.app/api/v1/repos/${{ github.repository }}/pulls?state=open&limit=100',
|
||||
|
||||
@@ -220,12 +220,14 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
|
||||
exit 1
|
||||
echo "::warning::AUTO_SYNC_TOKEN not set — using anonymous clone (repos are public per manifest.json OSS contract)"
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.manifest-stripped.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
|
||||
@@ -37,10 +37,13 @@ name: main-red-watchdog
|
||||
# "unknown on type" when `workflow_dispatch.inputs.X` is present. Revisit
|
||||
# when Gitea ≥ 1.23 is fleet-wide.
|
||||
on:
|
||||
schedule:
|
||||
# Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
|
||||
# offset from :17 (ci-required-drift) and :00 (peak cron load).
|
||||
- cron: '5 * * * *'
|
||||
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
|
||||
# Watchdog timing out behind runner saturation; rev3+dedicated-runner-label in flight
|
||||
# Re-enable after rev3 lands + runner saturation root resolved
|
||||
# schedule:
|
||||
# # Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
|
||||
# # offset from :17 (ci-required-drift) and :00 (peak cron load).
|
||||
# - cron: '5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Read commit status + branch ref + issues; write issues (open/PATCH/close).
|
||||
|
||||
@@ -54,6 +54,12 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
|
||||
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
|
||||
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
|
||||
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
|
||||
# See issue #576 + infra-lead pulse ~00:30Z.
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
continue-on-error: true
|
||||
@@ -79,8 +85,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
|
||||
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
|
||||
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
|
||||
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
|
||||
# See issue #576 + infra-lead pulse ~00:30Z.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -68,8 +74,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||
exit 1
|
||||
}
|
||||
@@ -92,13 +100,15 @@ jobs:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty"
|
||||
exit 1
|
||||
fi
|
||||
# clone-manifest.sh supports anonymous cloning for public repos (post-
|
||||
# 2026-05-08 migration). The token is only needed for private repos.
|
||||
# Do NOT require it — a missing secret would fail the build unnecessarily.
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.manifest-stripped.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: review-check-tests
|
||||
|
||||
# Runs review-check.sh regression tests on every PR + push that touches
|
||||
# the evaluator script or its test fixtures.
|
||||
#
|
||||
# Follows RFC#324 follow-up (issue #540):
|
||||
# .gitea/scripts/review-check.sh is load-bearing for PR merge gates.
|
||||
# It has ZERO production CI coverage. This workflow closes that gap.
|
||||
#
|
||||
# Design choices:
|
||||
# - Bash test harness (not bats). The existing test_review_check.sh
|
||||
# uses a custom assert_eq/assert_contains framework that is already
|
||||
# working and covers all 13 acceptance criteria (issue #540 §Acceptance).
|
||||
# Converting to bats would be refactoring, not closing the gap.
|
||||
# - No bats dependency: the runner-base image needs no extra tooling.
|
||||
# - continue-on-error: false — these tests must pass; a failure means
|
||||
# the review-gate evaluator is broken and must not be merged.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- '.gitea/scripts/review-check.sh'
|
||||
- '.gitea/scripts/tests/test_review_check.sh'
|
||||
- '.gitea/scripts/tests/_review_check_fixture.py'
|
||||
- '.gitea/workflows/review-check-tests.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- '.gitea/scripts/review-check.sh'
|
||||
- '.gitea/scripts/tests/test_review_check.sh'
|
||||
- '.gitea/scripts/tests/_review_check_fixture.py'
|
||||
- '.gitea/workflows/review-check-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: review-check.sh regression tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install jq
|
||||
# Required for T12 jq-filter test case. Gitea Actions runners (ubuntu-latest
|
||||
# label) do not bundle jq. Install via apt-get first (reliable for Ubuntu
|
||||
# runners with internet access to package mirrors). Falls back to GitHub
|
||||
# binary download. GitHub releases may be blocked on some runner networks
|
||||
# (infra#241 follow-up).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if apt-get update -qq && apt-get install -y -qq jq; then
|
||||
echo "::notice::jq installed via apt-get: $(jq --version)"
|
||||
elif timeout 120 curl -sSL \
|
||||
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
|
||||
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
|
||||
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
|
||||
else
|
||||
echo "::warning::jq install failed — apt-get and GitHub download both failed."
|
||||
fi
|
||||
jq --version 2>/dev/null || echo "::notice::jq not yet available — continuing"
|
||||
|
||||
- name: Run review-check.sh regression suite
|
||||
run: bash .gitea/scripts/tests/test_review_check.sh
|
||||
@@ -0,0 +1,118 @@
|
||||
# status-reaper — Option B (compensating-status POST) for Gitea 1.22.6's
|
||||
# hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
#
|
||||
# Tracking: molecule-core#? (this PR), internal#327 (sibling publish-runtime-bot),
|
||||
# internal#328 (sibling mc-drift-bot), internal#80 (upstream RFC). Sister
|
||||
# bots already deployed under the same per-persona-identity contract
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
#
|
||||
# Root cause:
|
||||
# Gitea 1.22.6 emits commit-status context as
|
||||
# `<workflow_name> / <job_name> (push)`
|
||||
# for ANY workflow run on the default branch's HEAD commit, REGARDLESS
|
||||
# of the trigger event. Schedule- and workflow_dispatch-triggered runs
|
||||
# on `main` therefore appear as `(push)` failures on the latest main
|
||||
# commit, painting main red via a fake-push status. Verified on runs
|
||||
# 14525 + 14526 via Phase 1 evidence (3 sub-agents). No upstream fix
|
||||
# in 1.23-1.26.1 (sibling a6f20db1 research).
|
||||
#
|
||||
# Why a cron-driven reaper, not workflow_run:
|
||||
# Gitea 1.22.6 does NOT support `on: workflow_run` (verified via
|
||||
# modules/actions/workflows.go enumeration; sister a6f20db1). The
|
||||
# only event-shaped option that fires is cron. 5min is chosen to
|
||||
# sit BETWEEN ci-required-drift (`:17` hourly) and main-red-watchdog
|
||||
# (`:05` hourly) so the reaper sweeps red before the watchdog files
|
||||
# a `[main-red]` issue (would-be false-positive).
|
||||
#
|
||||
# What the reaper does each tick:
|
||||
# 1. Parse `.gitea/workflows/*.yml`, classify each by whether `on:`
|
||||
# contains a `push:` trigger (see script for workflow_id resolution
|
||||
# including `name:` collision and `/`-in-name fail-loud lints).
|
||||
# 2. GET combined status for main HEAD.
|
||||
# 3. For each `failure` status whose context ends ` (push)`:
|
||||
# - if workflow has push trigger: PRESERVE (real defect signal).
|
||||
# - if workflow has no push trigger: POST a compensating
|
||||
# `state=success` with the same context and a description that
|
||||
# documents the workaround.
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Mutate non-`(push)`-suffix statuses (e.g. `(pull_request)` from
|
||||
# branch_protections required-checks — verified safe 2026-05-11).
|
||||
# - Auto-revert. Same reasoning as main-red-watchdog.
|
||||
# - Cancel runs. The runs themselves stay visible in Actions UI; the
|
||||
# fix is at the commit-status surface only.
|
||||
#
|
||||
# Removal path: drop this workflow when Gitea ≥ 1.24 ships with a
|
||||
# real fix for the hardcoded-suffix bug. Audit issue (filed post-merge)
|
||||
# tracks the deletion as a follow-up sweep.
|
||||
|
||||
name: status-reaper
|
||||
|
||||
# IMPORTANT — Gitea 1.22.6 parser quirk per
|
||||
# `feedback_gitea_workflow_dispatch_inputs_unsupported`: do NOT add an
|
||||
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
|
||||
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
|
||||
on:
|
||||
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
|
||||
# Reaper rev2 not compensating + watchdog timeout-cascade; rev3 in flight
|
||||
# Re-enable after rev3 lands + runner saturation root resolved
|
||||
# schedule:
|
||||
# # Every 5 minutes. Off-zero alignment with sibling cron workflows:
|
||||
# # ci-required-drift (`:17`), main-red-watchdog (`:05`),
|
||||
# # railway-pin-audit (`:23`). 5-min cadence gives a tight enough
|
||||
# # close on schedule-triggered false-reds that main-red-watchdog
|
||||
# # (hourly :05) almost never files an issue on the false case.
|
||||
# - cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Compensating-status POST needs write on repo statuses; no other
|
||||
# write surface is touched. checkout still needs `contents: read`.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# NOTE: NO `concurrency:` block is intentional.
|
||||
# Gitea 1.22.6 doesn't honor `cancel-in-progress: false`: queued ticks
|
||||
# of the same group get cancelled-with-started=0 instead of waiting
|
||||
# (DB-verified 2026-05-12, runs 16053/16085 of status-reaper.yml).
|
||||
# The reaper's POST /statuses/{sha} is idempotent — Gitea de-dups by
|
||||
# context — so concurrent ticks are safe; accept them rather than
|
||||
# serialise via the broken mechanism.
|
||||
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
# The script reads .gitea/workflows/*.yml from the working tree to
|
||||
# classify trigger sets; we must read main's CURRENT state, not
|
||||
# the SHA a stale schedule fired against.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Set up Python (PyYAML for workflow `on:` parse)
|
||||
# Pinned to 3.12 to match sibling watchdog / ci-required-drift.
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
# PyYAML is needed because shell-grep on `on:` misses list/string
|
||||
# forms and nested `push: { paths: ... }`. Same install pattern
|
||||
# as ci-required-drift.yml (sub-2s install, no wheel cache).
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
|
||||
- name: Compensate operational push-suffix failures on main
|
||||
env:
|
||||
# claude-status-reaper persona token; provisioned by sibling
|
||||
# aefaac1b 2026-05-11. Owns write:repository scope to POST
|
||||
# /statuses/{sha} but NOTHING ELSE
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
GITEA_TOKEN: ${{ secrets.STATUS_REAPER_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Weekly Platform-Go Surface
|
||||
|
||||
# Surface latent vet/test errors on main by running the full Platform-Go
|
||||
# suite on a weekly cron regardless of whether the last push touched
|
||||
# workspace-server/.
|
||||
#
|
||||
# Background: ci.yml's `platform-build` job gates real work on
|
||||
# `if: needs.changes.outputs.platform == 'true'`. When no push touches
|
||||
# workspace-server/, the skip fires and the suite never executes on main.
|
||||
# Latent vet errors and test flakes can sit for weeks undetected.
|
||||
#
|
||||
# This workflow runs the full suite (build, vet, golangci-lint, tests with
|
||||
# coverage) every Monday at 04:17 UTC. Results are posted as commit statuses
|
||||
# but continue-on-error: true means they never block anything — they're
|
||||
# purely a noise-reduction signal for when the next workspace-server push
|
||||
# lands and would otherwise trigger the first real suite run.
|
||||
#
|
||||
# Why 04:17 UTC on Monday: off-peak, before the weekly sprint cycle starts.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * 1' # Mondays at 04:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
weekly-platform-go:
|
||||
name: Weekly Platform-Go Surface
|
||||
runs-on: ubuntu-latest
|
||||
# continue-on-error: surface only, never block
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Go mod download
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: go build ./cmd/server
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./... || true
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./... || true
|
||||
|
||||
- name: Tests with race detection + coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Check coverage thresholds
|
||||
run: |
|
||||
set -e
|
||||
TOTAL_FLOOR=25
|
||||
CRITICAL_PATHS=(
|
||||
"internal/handlers/tokens"
|
||||
"internal/handlers/workspace_provision"
|
||||
"internal/handlers/a2a_proxy"
|
||||
"internal/handlers/registry"
|
||||
"internal/handlers/secrets"
|
||||
"internal/middleware/wsauth"
|
||||
"internal/crypto"
|
||||
)
|
||||
|
||||
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${TOTAL}%"
|
||||
if awk "BEGIN{exit !(\$TOTAL < \$TOTAL_FLOOR)}"; then
|
||||
echo "::error::Total coverage \${TOTAL}% is below the \${TOTAL_FLOOR}% floor."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ALLOWLIST=""
|
||||
if [ -f ../.coverage-allowlist.txt ]; then
|
||||
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
for path in "\${CRITICAL_PATHS[@]}"; do
|
||||
while read -r file pct; do
|
||||
[[ "$file" == *_test.go ]] && continue
|
||||
[[ "$file" == *"$path"* ]] || continue
|
||||
awk "BEGIN{exit !(\$pct < 10)}" || continue
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
continue
|
||||
fi
|
||||
echo "::error::Low coverage \${pct}% on \${rel} (below 10% in critical path \${path})"
|
||||
FAILED=$((FAILED + 1))
|
||||
done < <(go tool cover -func=coverage.out | grep -v '^total:' | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' | sort)
|
||||
done
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::error::\${FAILED} critical paths below 10% coverage — see above."
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage thresholds: OK"
|
||||
@@ -156,6 +156,16 @@ and run CI manually.
|
||||
| python-lint | pytest with coverage |
|
||||
| e2e-api | Full API test suite (62 tests) |
|
||||
| shellcheck | Shell script linting |
|
||||
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
|
||||
| ops-scripts | Python unittest suite for `scripts/*.py` |
|
||||
|
||||
## Local Testing
|
||||
|
||||
### review-check.sh
|
||||
```bash
|
||||
bash .gitea/scripts/tests/test_review_check.sh
|
||||
```
|
||||
Runs the full regression suite against a fixture HTTP server. No network access required.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -5,20 +5,22 @@
|
||||
* Covers: renders nothing when no approvals, polls /approvals/pending,
|
||||
* shows approval cards, approve/deny decisions, toast notifications.
|
||||
*
|
||||
* Note: does NOT mock @/lib/api — uses vi.spyOn on the real module.
|
||||
* vi.restoreAllMocks() is omitted from afterEach so queued mock values
|
||||
* (set up via mockResolvedValueOnce in beforeEach) are preserved for the
|
||||
* component's useEffect to consume.
|
||||
* Uses vi.hoisted + vi.mock (file-level) for @/lib/api. vi.resetModules()
|
||||
* in every afterEach undoes the mock so other test files that import the
|
||||
* real api module (e.g. socket.url.test.ts) are unaffected.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { ApprovalBanner } from "../ApprovalBanner";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
// ─── Hoisted mock refs ─────────────────────────────────────────────────────────
|
||||
// vi.hoisted runs in the same hoisting phase as vi.mock factories, so these
|
||||
// refs are stable across all tests and available inside the mock factory.
|
||||
const { mockApiGet, mockApiPost } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn<(args: unknown[]) => Promise<unknown>>(),
|
||||
mockApiPost: vi.fn<(args: unknown[]) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -41,28 +43,42 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
created_at: "2026-05-10T10:00:00Z",
|
||||
});
|
||||
|
||||
// Shared spy references so individual tests can reset or reject the POST mock
|
||||
// without needing to call spyOn again (which would create a duplicate spy).
|
||||
let mockGet: ReturnType<typeof vi.spyOn>;
|
||||
let mockPost: ReturnType<typeof vi.spyOn>;
|
||||
// ─── Static mocks (file-level — no other test needs the real modules) ─────────
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// vi.resetModules() in afterEach undoes this mock so other files that import
|
||||
// the real api module are unaffected.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockApiGet,
|
||||
post: mockApiPost,
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ApprovalBanner — empty state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
mockApiGet.mockReset().mockResolvedValue([]);
|
||||
mockApiPost.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("renders nothing when there are no pending approvals", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(mockApiGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render any approve/deny buttons when list is empty", async () => {
|
||||
@@ -76,41 +92,40 @@ describe("ApprovalBanner — empty state", () => {
|
||||
describe("ApprovalBanner — renders approval cards", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGet = vi.spyOn(api, "get").mockResolvedValueOnce([
|
||||
mockApiGet.mockReset().mockResolvedValue([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
]);
|
||||
mockApiPost.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("renders an alert card for each pending approval", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(2);
|
||||
mockGet.mockRestore();
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("displays the workspace name and action text", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const nameEls = screen.getAllByText(/test workspace needs approval/i);
|
||||
expect(nameEls).toHaveLength(2);
|
||||
expect(screen.getAllByText(/test workspace needs approval/i)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("displays the reason when present", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const reasons = screen.getAllByText(/requires human approval/i);
|
||||
expect(reasons).toHaveLength(2);
|
||||
expect(screen.getAllByText(/requires human approval/i)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("omits the reason div when reason is null", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([{
|
||||
mockApiGet.mockReset().mockResolvedValue([{
|
||||
...pendingApproval("a1"),
|
||||
reason: null,
|
||||
}]);
|
||||
@@ -124,7 +139,6 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtns = screen.getAllByRole("button", { name: /Approve/i });
|
||||
const denyBtns = screen.getAllByRole("button", { name: /Deny/i });
|
||||
// 2 cards, each card has 1 Approve + 1 Deny button → 2 of each minimum
|
||||
expect(approveBtns.length).toBeGreaterThanOrEqual(2);
|
||||
expect(denyBtns.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -132,21 +146,22 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
it("has aria-live=assertive on the alert container", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const alert = screen.getAllByRole("alert")[0];
|
||||
expect(alert.getAttribute("aria-live")).toBe("assertive");
|
||||
expect(screen.getAllByRole("alert")[0].getAttribute("aria-live")).toBe("assertive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalBanner — decisions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGet = vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockPost = vi.spyOn(api, "post").mockResolvedValue({});
|
||||
mockApiGet.mockReset().mockResolvedValue([pendingApproval("a1")]);
|
||||
mockApiPost.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
|
||||
@@ -154,7 +169,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
expect.objectContaining({ decision: "approved" })
|
||||
);
|
||||
@@ -165,7 +180,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /deny/i })[0]);
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
expect.objectContaining({ decision: "denied" })
|
||||
);
|
||||
@@ -197,7 +212,10 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows an error toast when POST fails", async () => {
|
||||
mockPost.mockReset().mockRejectedValue(new Error("Network error"));
|
||||
// mockImplementation preserves the vi.fn() wrapper (unlike mockReset() which
|
||||
// strips it and causes the real fetch() to fire — the root cause of the
|
||||
// original flakiness in this file).
|
||||
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
|
||||
@@ -209,9 +227,9 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("keeps the card visible when the POST fails", async () => {
|
||||
// Reset the post mock before rejecting so the beforeEach's resolved value
|
||||
// is gone and we get a clean rejection instead of a resolved→rejected queue.
|
||||
mockPost.mockReset().mockRejectedValue(new Error("Network error"));
|
||||
// Same mockImplementation pattern — preserves the wrapper so the component's
|
||||
// catch block runs instead of the real fetch().
|
||||
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
|
||||
@@ -223,12 +241,15 @@ describe("ApprovalBanner — decisions", () => {
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
mockApiGet.mockReset().mockResolvedValue([]);
|
||||
mockApiPost.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("shows nothing when the API returns an empty array on first poll", async () => {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EmptyState — the full-canvas welcome card shown on first load.
|
||||
*
|
||||
* Covers:
|
||||
* - Loading state (GET /templates in flight)
|
||||
* - Fetch failure → empty template grid (templates = [])
|
||||
* - Template grid renders with correct content
|
||||
* - Template button disabled while deploying
|
||||
* - "Deploying..." label on the button being deployed
|
||||
* - "Create blank" button POSTs /workspaces
|
||||
* - "Creating..." label while blank workspace is being created
|
||||
* - Blank create error shows error banner
|
||||
* - Error banner has role="alert"
|
||||
* - All buttons disabled while any deploy is in-flight
|
||||
* - handleDeployed fires after 500ms delay
|
||||
*
|
||||
* Uses vi.hoisted + vi.mock to fully isolate the api module, matching
|
||||
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
// ─── Hoisted mock refs ─────────────────────────────────────────────────────────
|
||||
// vi.hoisted runs in the same hoisting phase as vi.mock factories, so all refs
|
||||
// are available both to the factory and to test bodies.
|
||||
const { mockApiGet, mockApiPost } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn<(args: unknown[]) => Promise<unknown>>(),
|
||||
mockApiPost: vi.fn<(args: unknown[]) => Promise<{ id: string }>>(),
|
||||
}));
|
||||
|
||||
// Mutable deploy state — object reference is const; properties can be mutated.
|
||||
const _deploy = vi.hoisted(() => ({
|
||||
deployFn: vi.fn(),
|
||||
deploying: undefined as string | undefined,
|
||||
error: undefined as string | undefined,
|
||||
modal: null as React.ReactNode,
|
||||
}));
|
||||
|
||||
const { mockSelectNode, mockSetPanelTab } = vi.hoisted(() => ({
|
||||
mockSelectNode: vi.fn(),
|
||||
mockSetPanelTab: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockApiGet,
|
||||
post: mockApiPost,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTemplateDeploy", () => ({
|
||||
useTemplateDeploy: () => ({
|
||||
deploy: _deploy.deployFn,
|
||||
deploying: _deploy.deploying,
|
||||
error: _deploy.error,
|
||||
modal: _deploy.modal,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: { getState: () => { selectNode: typeof mockSelectNode; setPanelTab: typeof mockSetPanelTab } }) => unknown) =>
|
||||
selector({
|
||||
getState: () => ({
|
||||
selectNode: mockSelectNode,
|
||||
setPanelTab: mockSetPanelTab,
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ getState: () => ({ selectNode: mockSelectNode, setPanelTab: mockSetPanelTab }) }
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../TemplatePalette", () => ({
|
||||
OrgTemplatesSection: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({
|
||||
Spinner: () => <span data-testid="spinner">⟳</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
TIER_CONFIG: {
|
||||
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-violet-600 border-violet-500" },
|
||||
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATE = {
|
||||
id: "tpl-1",
|
||||
name: "Claude Code Agent",
|
||||
description: "A general-purpose coding assistant",
|
||||
tier: 2,
|
||||
skill_count: 3,
|
||||
model: "claude-opus-4-5",
|
||||
};
|
||||
|
||||
function template(overrides: Partial<typeof TEMPLATE> = {}): typeof TEMPLATE {
|
||||
return { ...TEMPLATE, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderEmpty() {
|
||||
return render(<EmptyState />);
|
||||
}
|
||||
|
||||
// Flush React state + microtasks after an act boundary.
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// Reset deploy state to defaults before each test.
|
||||
function resetDeployState() {
|
||||
_deploy.deployFn.mockReset();
|
||||
_deploy.deploying = undefined;
|
||||
_deploy.error = undefined;
|
||||
_deploy.modal = null;
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmptyState — loading", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset().mockImplementation(
|
||||
() => new Promise(() => {}) // never resolves
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state while GET /templates is pending", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByTestId("spinner")).toBeTruthy();
|
||||
expect(screen.getByText("Loading templates...")).toBeTruthy();
|
||||
});
|
||||
|
||||
// "create blank" is rendered outside the loading/template-grid conditional,
|
||||
// so it is always visible — adjust expectation accordingly.
|
||||
it("renders 'create blank' button during loading", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render template buttons while loading", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — templates", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset().mockResolvedValue([template()]);
|
||||
resetDeployState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the welcome heading", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Deploy your first agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders template buttons with name and description", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders tier badge and skill count", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
// skill_count renders as "3 skills · <model>"
|
||||
expect(screen.getByText(/^3 skills/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders model name when present", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText(/claude-opus/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls deploy with the template on click", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("Claude Code Agent"));
|
||||
expect(_deploy.deployFn).toHaveBeenCalledWith(template());
|
||||
});
|
||||
|
||||
it("shows 'Deploying...' on the button of the template being deployed", async () => {
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Deploying...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the template button of the deploying template", async () => {
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables 'create blank' while a template is deploying", async () => {
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — fetch failure / empty templates", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset().mockResolvedValue([]);
|
||||
resetDeployState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not render template grid when GET /templates returns []", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'create blank' button when templates list is empty", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render template grid when GET /templates rejects", async () => {
|
||||
mockApiGet.mockReset().mockRejectedValue(new Error("Network failure"));
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — create blank", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset().mockResolvedValue([template()]);
|
||||
mockApiPost.mockReset().mockResolvedValue({ id: "ws-new" });
|
||||
resetDeployState();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls POST /workspaces on 'create blank' click", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({ name: "My First Agent" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows 'Creating...' while blank workspace POST is pending", async () => {
|
||||
mockApiPost.mockReset().mockImplementation(
|
||||
() => new Promise(() => {}) // never resolves
|
||||
);
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole("button", { name: "Creating..." })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls selectNode + setPanelTab after 500ms on successful create", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); }); // flush POST
|
||||
await act(async () => { vi.advanceTimersByTime(500); });
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("ws-new");
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
|
||||
});
|
||||
|
||||
it("disables template buttons while creating blank workspace", async () => {
|
||||
mockApiPost.mockReset().mockImplementation(
|
||||
() => new Promise(() => {}) // never resolves
|
||||
);
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect((screen.getByText("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows error banner when POST /workspaces fails", async () => {
|
||||
mockApiPost.mockReset().mockRejectedValue(new Error("Server error"));
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears 'Creating...' and shows button again after POST failure", async () => {
|
||||
mockApiPost.mockReset().mockRejectedValue(new Error("Server error"));
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
// After rejection, blankCreating = false → button reverts to default label
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — error banner", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset().mockResolvedValue([template()]);
|
||||
resetDeployState();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("has role=alert on the error banner", async () => {
|
||||
_deploy.error = "Template deploy failed";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert.textContent).toContain("Template deploy failed");
|
||||
});
|
||||
|
||||
it("does not show error banner when no errors", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* palette-context: MobileAccentProvider + usePalette hook coverage.
|
||||
*
|
||||
* Covers:
|
||||
* - usePalette(dark=false) without provider → MOL_LIGHT
|
||||
* - usePalette(dark=true) without provider → MOL_DARK
|
||||
* - usePalette with provider accent=null → base palette unchanged
|
||||
* - usePalette with provider accent=base.accent → base palette unchanged (identity guard)
|
||||
* - usePalette with provider accent="#ff0000" → accent + online overridden
|
||||
* - MobileAccentProvider renders children
|
||||
* - Never mutates the static MOL_LIGHT/MOL_DARK singletons
|
||||
*
|
||||
* The pure functions (getPalette, normalizeStatus, tierCode) are covered
|
||||
* in palette.test.ts — only the React context/hook is tested here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileAccentProvider, usePalette } from "../palette-context";
|
||||
import { MOL_DARK, MOL_LIGHT } from "../palette";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Test helpers ──────────────────────────────────────────────────────────────
|
||||
// Each helper renders exactly one usePalette value as a testid element.
|
||||
// Using unique testids per scenario avoids "multiple elements" DOM pollution
|
||||
// when tests run in the same jsdom worker without strict cleanup timing.
|
||||
|
||||
function AccentDump({ dark }: { dark: boolean }) {
|
||||
const palette = usePalette(dark);
|
||||
return <span data-testid="accent-val">{palette.accent}</span>;
|
||||
}
|
||||
|
||||
function OnlineDump({ dark }: { dark: boolean }) {
|
||||
const palette = usePalette(dark);
|
||||
return <span data-testid="online-val">{palette.online}</span>;
|
||||
}
|
||||
|
||||
// ─── MobileAccentProvider ──────────────────────────────────────────────────────
|
||||
describe("MobileAccentProvider", () => {
|
||||
it("renders children", () => {
|
||||
const { getByText } = render(
|
||||
<MobileAccentProvider accent={null}>
|
||||
<span>child content</span>
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(getByText("child content").textContent).toBe("child content");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── usePalette — no provider ─────────────────────────────────────────────────
|
||||
describe("usePalette without MobileAccentProvider", () => {
|
||||
it("returns MOL_LIGHT when dark=false", () => {
|
||||
const { getByTestId } = render(<AccentDump dark={false} />);
|
||||
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("returns MOL_DARK when dark=true", () => {
|
||||
const { getByTestId } = render(<AccentDump dark={true} />);
|
||||
expect(getByTestId("accent-val").textContent).toBe(MOL_DARK.accent);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── usePalette — with MobileAccentProvider ────────────────────────────────────
|
||||
describe("usePalette with MobileAccentProvider", () => {
|
||||
it("returns base palette unchanged when accent=null", () => {
|
||||
const { getByTestId } = render(
|
||||
<MobileAccentProvider accent={null}>
|
||||
<AccentDump dark={false} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("returns base palette unchanged when accent matches base.accent (identity guard)", () => {
|
||||
const { getByTestId } = render(
|
||||
<MobileAccentProvider accent={MOL_LIGHT.accent}>
|
||||
<AccentDump dark={false} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("overrides accent when provider supplies a different colour", () => {
|
||||
const CUSTOM = "#ff0000";
|
||||
const { getByTestId } = render(
|
||||
<MobileAccentProvider accent={CUSTOM}>
|
||||
<AccentDump dark={false} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(getByTestId("accent-val").textContent).toBe(CUSTOM);
|
||||
});
|
||||
|
||||
it("also overrides online when accent is overridden", () => {
|
||||
const CUSTOM = "#ff8800";
|
||||
const { getByTestId } = render(
|
||||
<MobileAccentProvider accent={CUSTOM}>
|
||||
<OnlineDump dark={false} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(getByTestId("online-val").textContent).toBe(CUSTOM);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Immutability ─────────────────────────────────────────────────────────────
|
||||
describe("MOL_LIGHT and MOL_DARK singletons are never mutated", () => {
|
||||
it("MOL_LIGHT.accent unchanged after custom-accent render", () => {
|
||||
const before = MOL_LIGHT.accent;
|
||||
render(
|
||||
<MobileAccentProvider accent="#deadbeef">
|
||||
<AccentDump dark={false} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(MOL_LIGHT.accent).toBe(before);
|
||||
});
|
||||
|
||||
it("MOL_DARK.accent unchanged after custom-accent render", () => {
|
||||
const before = MOL_DARK.accent;
|
||||
render(
|
||||
<MobileAccentProvider accent="#bada55ff">
|
||||
<AccentDump dark={true} />
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(MOL_DARK.accent).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
|
||||
);
|
||||
}
|
||||
|
||||
function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
|
||||
export function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
|
||||
if (!card) return [];
|
||||
const skills = card.skills;
|
||||
if (!Array.isArray(skills)) return [];
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
|
||||
*
|
||||
* NotAvailablePanel: pure presentational component — renders a "feature not
|
||||
* available" placeholder for external-runtime workspaces.
|
||||
* FilesToolbar: pure props-driven component — directory selector, file count,
|
||||
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
|
||||
*
|
||||
* No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
|
||||
// ─── afterEach ─────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
it("renders heading 'Files not available'", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("Files not available");
|
||||
});
|
||||
|
||||
it("renders the runtime name in monospace", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("external");
|
||||
const spans = container.querySelectorAll("span");
|
||||
const monoSpans = Array.from(spans).filter(
|
||||
(s) => s.className && s.className.includes("font-mono"),
|
||||
);
|
||||
expect(monoSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a Chat tab hint in description", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
|
||||
expect(container.textContent).toContain("Chat tab");
|
||||
});
|
||||
|
||||
it("SVG icon has aria-hidden=true", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders without crashing for any runtime string", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
|
||||
expect(container.textContent).toContain("unknown-runtime");
|
||||
});
|
||||
|
||||
it("applies the correct layout classes to root div", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain("flex");
|
||||
expect(root.className).toContain("flex-col");
|
||||
expect(root.className).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
const noop = vi.fn();
|
||||
|
||||
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={noop}
|
||||
fileCount={0}
|
||||
onNewFile={noop}
|
||||
onUpload={noop}
|
||||
onDownloadAll={noop}
|
||||
onClearAll={noop}
|
||||
onRefresh={noop}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the directory selector with correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("aria-label")).toBe("File root directory");
|
||||
});
|
||||
|
||||
it("directory selector has all four options", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain("/configs");
|
||||
expect(values).toContain("/home");
|
||||
expect(values).toContain("/workspace");
|
||||
expect(values).toContain("/plugins");
|
||||
});
|
||||
|
||||
it("calls setRoot when directory changes", () => {
|
||||
const setRoot = vi.fn();
|
||||
const { container } = renderToolbar({ setRoot });
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "/home";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(setRoot).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("displays the file count", () => {
|
||||
const { container } = renderToolbar({ fileCount: 42 });
|
||||
expect(container.textContent).toContain("42 files");
|
||||
});
|
||||
|
||||
it("shows New + Upload + Clear buttons for /configs", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).toContain("+ New");
|
||||
expect(texts).toContain("Upload");
|
||||
expect(texts).toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(texts).toContain("↻");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /workspace", () => {
|
||||
const { container } = renderToolbar({ root: "/workspace" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /home", () => {
|
||||
const { container } = renderToolbar({ root: "/home" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /plugins", () => {
|
||||
const { container } = renderToolbar({ root: "/plugins" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("New button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const newBtn = container.querySelector('button[aria-label="Create new file"]');
|
||||
expect(newBtn?.textContent?.trim()).toBe("+ New");
|
||||
});
|
||||
|
||||
it("Export button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
|
||||
expect(exportBtn?.textContent?.trim()).toBe("Export");
|
||||
});
|
||||
|
||||
it("Clear button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
|
||||
expect(clearBtn?.textContent?.trim()).toBe("Clear");
|
||||
});
|
||||
|
||||
it("Refresh button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
|
||||
expect(refreshBtn?.textContent?.trim()).toBe("↻");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onNewFile });
|
||||
container.querySelector('button[aria-label="Create new file"]')!.click();
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
const { container } = renderToolbar({ onDownloadAll });
|
||||
container.querySelector('button[aria-label="Download all files"]')!.click();
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onClearAll });
|
||||
container.querySelector('button[aria-label="Delete all files"]')!.click();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const { container } = renderToolbar({ onRefresh });
|
||||
container.querySelector('button[aria-label="Refresh file list"]')!.click();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies focus-visible ring to all interactive buttons", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const buttons = container.querySelectorAll("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn.className).toContain("focus-visible:ring-2");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
|
||||
export function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
|
||||
if (!agentCard) return [];
|
||||
const rawSkills = agentCard.skills;
|
||||
if (!Array.isArray(rawSkills)) return [];
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { BudgetSection } from "../BudgetSection";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Queue-based mock for the api module. Each api call shifts from the queue.
|
||||
// Tests push with qGet/qPatch and the module-level mockImplementation
|
||||
// reads from the queue.
|
||||
type QueueEntry = { body?: unknown; err?: Error };
|
||||
const apiQueue: QueueEntry[] = [];
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(async (path: string) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.get queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
patch: vi.fn(async (path: string, _body?: unknown) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.patch queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
apiQueue.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const WS_ID = "budget-test-ws";
|
||||
|
||||
function qGet(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qGetErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function qPatch(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qPatchErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function makeBudget(overrides: Partial<{
|
||||
budget_limit: number | null;
|
||||
budget_used: number;
|
||||
budget_remaining: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
budget_limit: 10_000,
|
||||
budget_used: 3_500,
|
||||
budget_remaining: 6_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BudgetSection", () => {
|
||||
describe("loading state", () => {
|
||||
it("shows loading indicator while fetching", async () => {
|
||||
let resolveGet: (v: unknown) => void;
|
||||
vi.mocked(api.get).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
expect(screen.getByTestId("budget-loading")).toBeTruthy();
|
||||
|
||||
// Resolve after render to verify state clears
|
||||
resolveGet!(makeBudget());
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch error state", () => {
|
||||
it("shows error message on non-402 fetch failure", async () => {
|
||||
qGetErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
|
||||
});
|
||||
|
||||
it("shows 402 as exceeded banner, not fetch error", async () => {
|
||||
// 402 means the budget limit was hit — different UX from a network/API error.
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget loaded — display", () => {
|
||||
it("renders used / limit stats row", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
|
||||
});
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when budget_limit is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders remaining credits when present", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits remaining credits when budget_remaining is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-remaining")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps progress bar at 100% when used > limit", async () => {
|
||||
// Over-limit: 12000 used of 10000 limit should show 100%, not 120%.
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.getAttribute("style")).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits progress bar when budget_limit is null (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget exceeded (402)", () => {
|
||||
it("shows exceeded banner when load returns 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears exceeded banner after successful save", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "50000" } });
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("save flow", () => {
|
||||
it("shows save error on non-402 patch failure", async () => {
|
||||
qGet(makeBudget());
|
||||
qPatchErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates input to new limit value after successful save", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: 20_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
// Wait for the input to appear (loading → loaded)
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
// Debug: check what values are rendered
|
||||
const limitValue = screen.getByTestId("budget-limit-value")?.textContent;
|
||||
expect(input.value).toBe("10000"); // initial value from API
|
||||
expect(limitValue).toBe("10,000");
|
||||
|
||||
fireEvent.change(input, { target: { value: "20000" } });
|
||||
expect(input.value).toBe("20000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null when input is cleared (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// After save with null limit, input should show empty (unlimited)
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows saving state on button while patch is in flight", async () => {
|
||||
qGet(makeBudget());
|
||||
let resolvePatch: (v: unknown) => void;
|
||||
vi.mocked(api.patch).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
const btn = screen.getByTestId("budget-save-btn");
|
||||
expect(btn.textContent).toContain("Saving");
|
||||
|
||||
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.textContent).toContain("Save");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApiError402 — regression coverage", () => {
|
||||
it("classifies ': 402' with space as 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget());
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies non-402 error messages as regular fetch errors", async () => {
|
||||
qGetErr(503, "Service Unavailable");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for extractSkills — pure helper from SkillsTab.
|
||||
*
|
||||
* Covers: null card, non-array skills, empty skills, full skill entries
|
||||
* (id, name, description, tags, examples), id-only fallback, name-only
|
||||
* fallback, string coercion, array coercion for tags/examples,
|
||||
* filtering entries with no id after coercion, empty string id (filtered).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractSkills } from "../SkillsTab";
|
||||
|
||||
describe("extractSkills", () => {
|
||||
it("returns [] for null card", () => {
|
||||
expect(extractSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when card.skills is not an array", () => {
|
||||
expect(extractSkills({ skills: undefined })).toEqual([]);
|
||||
expect(extractSkills({ skills: "not-an-array" })).toEqual([]);
|
||||
expect(extractSkills({ skills: { id: "x" } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty skills array", () => {
|
||||
expect(extractSkills({ skills: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps a fully-populated skill entry", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{
|
||||
id: "code_search",
|
||||
name: "Code Search",
|
||||
description: "Semantic code search",
|
||||
tags: ["search", "code"],
|
||||
examples: ["Find unused exports", "Search by AST pattern"],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{
|
||||
id: "code_search",
|
||||
name: "Code Search",
|
||||
description: "Semantic code search",
|
||||
tags: ["search", "code"],
|
||||
examples: ["Find unused exports", "Search by AST pattern"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses name as id when id is absent", () => {
|
||||
const card = { skills: [{ name: "web_scraper" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses id as name when name is absent", () => {
|
||||
const card = { skills: [{ id: "legacy_skill" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out entries with neither id nor name", () => {
|
||||
// id: String(undefined || undefined || "") → "" → filtered (id.length = 0)
|
||||
const card = { skills: [{ description: "orphan entry" }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries with no id after string coercion", () => {
|
||||
// id resolves to "" after String(undefined || null || {})
|
||||
const card = { skills: [{ id: null, name: null }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries with empty-string id", () => {
|
||||
const card = { skills: [{ id: "", name: "" }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("coerces numeric tags to strings", () => {
|
||||
const card = { skills: [{ id: "x", tags: [1, "two", 3] }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("coerces non-array tags to empty array", () => {
|
||||
const card = { skills: [{ id: "x", tags: "not-an-array" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("coerces non-array examples to empty array", () => {
|
||||
const card = { skills: [{ id: "x", examples: 42 }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
// NOTE: extractSkills uses `String(skill.description || "")` — falsy values
|
||||
// (0, null, false) fall through to "", NOT to their string form.
|
||||
it("returns '' for falsy description values (0, null, false)", () => {
|
||||
const card = { skills: [{ id: "x", description: 0 }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mixed valid/invalid entries", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "valid_one", name: "One" },
|
||||
{ name: "named_only" },
|
||||
{ description: "orphan" }, // filtered — id becomes ""
|
||||
{ id: "valid_two", examples: ["a", "b"] },
|
||||
],
|
||||
};
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "valid_one", name: "One", description: "", tags: [], examples: [] },
|
||||
{ id: "named_only", name: "named_only", description: "", tags: [], examples: [] },
|
||||
{ id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a realistic agent card with multiple skills", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] },
|
||||
{ id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] },
|
||||
],
|
||||
};
|
||||
const result = extractSkills(card);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe("web_search");
|
||||
expect(result[1].tags).toEqual(["io"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for getSkills — pure helper from DetailsTab.
|
||||
*
|
||||
* Covers: null card, non-array skills, empty skills, id-only entries,
|
||||
* name-only entries (id derives from name), entries with description,
|
||||
* entries with neither id nor name (filtered out), mixed entries.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getSkills } from "../DetailsTab";
|
||||
|
||||
describe("getSkills", () => {
|
||||
it("returns [] for null card", () => {
|
||||
expect(getSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when card.skills is not an array", () => {
|
||||
expect(getSkills({ skills: undefined })).toEqual([]);
|
||||
expect(getSkills({ skills: "not-an-array" })).toEqual([]);
|
||||
expect(getSkills({ skills: { id: "x" } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty skills array", () => {
|
||||
expect(getSkills({ skills: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps skill with id and description", () => {
|
||||
const card = { skills: [{ id: "code_search", description: "Find code patterns" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]);
|
||||
});
|
||||
|
||||
it("maps skill with id only (description absent)", () => {
|
||||
const card = { skills: [{ id: "code_search" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]);
|
||||
});
|
||||
|
||||
it("derives id from name when id is absent", () => {
|
||||
const card = { skills: [{ name: "web_scraper" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "web_scraper" }]);
|
||||
});
|
||||
|
||||
it("maps description when present", () => {
|
||||
const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]);
|
||||
});
|
||||
|
||||
it("returns description as undefined when skill has no description", () => {
|
||||
const card = { skills: [{ id: "noop_skill" }] };
|
||||
const result = getSkills(card);
|
||||
// The map always includes description; it's undefined when absent
|
||||
expect(result).toEqual([{ id: "noop_skill", description: undefined }]);
|
||||
});
|
||||
|
||||
it("filters out skills with neither id nor name", () => {
|
||||
// id: String(undefined || undefined || "") → "" → filtered
|
||||
const card = { skills: [{ description: "loner" }] };
|
||||
expect(getSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles mixed valid/invalid entries", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "valid_one" },
|
||||
{ name: "named_skill" },
|
||||
{ description: "orphaned" }, // filtered
|
||||
{ id: "valid_two", description: "Has both" },
|
||||
],
|
||||
};
|
||||
expect(getSkills(card)).toEqual([
|
||||
{ id: "valid_one", description: undefined },
|
||||
{ id: "named_skill", description: undefined },
|
||||
{ id: "valid_two", description: "Has both" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles string coercion for numeric ids/names", () => {
|
||||
const card = { skills: [{ id: 42, name: "numeric_id" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "42" }]);
|
||||
});
|
||||
|
||||
it("uses id over name when both are present", () => {
|
||||
const card = { skills: [{ id: "priority_id", name: "fallback_name" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]);
|
||||
});
|
||||
|
||||
it("omits description when it is falsy (0 is falsy in JS)", () => {
|
||||
// The implementation uses `s.description ?` — 0 is falsy, so it's treated
|
||||
// as absent and undefined is returned. Non-zero numbers coerce fine.
|
||||
const cardZero = { skills: [{ id: "x", description: 0 }] };
|
||||
expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]);
|
||||
|
||||
const cardNum = { skills: [{ id: "x", description: 42 }] };
|
||||
expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentViews — pure presentational components for chat attachments.
|
||||
*
|
||||
* Covers:
|
||||
* - PendingAttachmentPill renders file name, formatted size, × button
|
||||
* - PendingAttachmentPill × button has correct aria-label
|
||||
* - PendingAttachmentPill calls onRemove when × clicked
|
||||
* - PendingAttachmentPill renders exactly one button
|
||||
* - AttachmentChip renders attachment name and download glyph
|
||||
* - AttachmentChip renders size when provided
|
||||
* - AttachmentChip omits size span when size is undefined
|
||||
* - AttachmentChip calls onDownload(attachment) on click
|
||||
* - AttachmentChip title attribute for hover tooltip
|
||||
* - AttachmentChip tone=user applies blue accent classes
|
||||
* - AttachmentChip tone=agent applies surface classes
|
||||
* - AttachmentChip renders exactly one button
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors in this vitest
|
||||
* configuration.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentChip, PendingAttachmentPill } from "../AttachmentViews";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a File with actual content so size > 0 in jsdom. */
|
||||
function makeFile(name: string, content: string): File {
|
||||
return new File([content], name, { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
// ─── PendingAttachmentPill ─────────────────────────────────────────────────────
|
||||
|
||||
describe("PendingAttachmentPill", () => {
|
||||
it("renders the file name", () => {
|
||||
const file = makeFile("report.pdf", "PDF content here");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("report.pdf");
|
||||
});
|
||||
|
||||
it("renders the formatted file size (KB)", () => {
|
||||
// 50 KB = 50 * 1024 bytes
|
||||
const content = "x".repeat(50 * 1024);
|
||||
const file = makeFile("data.csv", content);
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("50 KB");
|
||||
});
|
||||
|
||||
it("renders 0 B for empty file", () => {
|
||||
const file = makeFile("empty.txt", "");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("0 B");
|
||||
});
|
||||
|
||||
it("renders size in MB for files >= 1 MB", () => {
|
||||
// 2.5 MB = 2.5 * 1024 * 1024 bytes
|
||||
const content = "x".repeat(Math.round(2.5 * 1024 * 1024));
|
||||
const file = makeFile("video.mp4", content);
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("2.5 MB");
|
||||
});
|
||||
|
||||
it("× button has aria-label with file name", () => {
|
||||
const file = makeFile("notes.txt", "some content");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.getAttribute("aria-label")).toBe("Remove notes.txt");
|
||||
});
|
||||
|
||||
it("calls onRemove when × button is clicked", () => {
|
||||
const file = makeFile("doc.pdf", "pdf data");
|
||||
const onRemove = vi.fn();
|
||||
render(<PendingAttachmentPill file={file} onRemove={onRemove} />);
|
||||
screen.getByRole("button").click();
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders exactly one button (the × remove button)", () => {
|
||||
const file = makeFile("img.png", "image bytes");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AttachmentChip ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentChip", () => {
|
||||
it("renders the attachment name", () => {
|
||||
const att = makeAttachment("chart.svg", 2048);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.textContent).toContain("chart.svg");
|
||||
});
|
||||
|
||||
it("renders size when provided", () => {
|
||||
const att = makeAttachment("dump.sql", 1024 * 150); // 150 KB
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.textContent).toContain("150 KB");
|
||||
});
|
||||
|
||||
it("omits size span when attachment.size is undefined", () => {
|
||||
const att = makeAttachment("notes.md"); // no size
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
// The only <span> should be the truncated filename; no size <span>
|
||||
const spans = Array.from(container.querySelectorAll("span"));
|
||||
const sizeSpans = spans.filter(
|
||||
(s) => s.className && s.className.includes("tabular-nums"),
|
||||
);
|
||||
expect(sizeSpans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("has title attribute with download hint", () => {
|
||||
const att = makeAttachment("readme.txt", 64);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
|
||||
);
|
||||
const btn = container.querySelector("button");
|
||||
expect(btn?.getAttribute("title")).toBe("Download readme.txt");
|
||||
});
|
||||
|
||||
it("calls onDownload with the attachment on click", () => {
|
||||
const att = makeAttachment("export.csv", 8192);
|
||||
const onDownload = vi.fn();
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />,
|
||||
);
|
||||
container.querySelector("button")!.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("tone=user applies blue accent class", () => {
|
||||
const att = makeAttachment("photo.jpg", 512);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
const btn = container.querySelector("button")!;
|
||||
expect(btn.className).toContain("blue-400");
|
||||
});
|
||||
|
||||
it("tone=agent does not apply blue accent class", () => {
|
||||
const att = makeAttachment("photo.jpg", 512);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
|
||||
);
|
||||
const btn = container.querySelector("button")!;
|
||||
expect(btn.className).not.toContain("blue-400");
|
||||
});
|
||||
|
||||
it("renders exactly one button", () => {
|
||||
const att = makeAttachment("icon.svg", 128);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for KeyValueField component.
|
||||
*
|
||||
* Covers: initial password type, onChange callback (including whitespace trim
|
||||
* on type), aria-label forwarding, disabled state, and auto-hide timer setup.
|
||||
*/
|
||||
import React from "react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { KeyValueField } from "../KeyValueField";
|
||||
|
||||
describe("KeyValueField — rendering", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders input with type=password by default (secret hidden)", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
expect(input.getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("passes custom aria-label to the input element", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} aria-label="API secret key" />);
|
||||
expect(screen.getByLabelText("API secret key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the input when disabled=true", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} disabled />);
|
||||
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with the current value", () => {
|
||||
render(<KeyValueField value="sk-test-key-123" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
|
||||
});
|
||||
|
||||
it("renders with the placeholder text", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
|
||||
});
|
||||
|
||||
it("renders the RevealToggle child button", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// KeyValueField renders exactly one button (the RevealToggle)
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — onChange", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("calls onChange with the new value when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("new-value");
|
||||
});
|
||||
|
||||
it("trims leading whitespace when user types with leading space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace when user types with trailing space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims both sides when user types whitespace-surrounded value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
|
||||
expect(onChange).toHaveBeenCalledWith("both sides");
|
||||
});
|
||||
|
||||
it("does not modify value with no whitespace", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("clean-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — auto-hide timer setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// No timer should be set initially (revealed=false by default)
|
||||
const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
|
||||
|
||||
// Simulate reveal (click the only button)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// After reveal, a 30s timer should be set
|
||||
const timerCalls = setTimeoutSpy.mock.calls.filter(
|
||||
([, delay]) => delay === 30_000,
|
||||
);
|
||||
expect(timerCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("clears existing timer when a new toggle happens before auto-hide fires", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const timerObj = {}; // fake timer ID
|
||||
vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
|
||||
return timerObj;
|
||||
});
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// First toggle — reveal
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// Second toggle — hide (should clear the timer from first toggle)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// clearTimeout was called with the timer object
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
|
||||
});
|
||||
|
||||
it("clears timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const { unmount } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// Toggle reveal to start the timer
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
unmount();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for RevealToggle component.
|
||||
*
|
||||
* Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
|
||||
* aria-label (default + custom), title attribute.
|
||||
*/
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { RevealToggle } from "../RevealToggle";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RevealToggle", () => {
|
||||
it("renders as a button", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses default aria-label when not provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
|
||||
});
|
||||
|
||||
it("uses custom aria-label when provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
|
||||
});
|
||||
|
||||
it('title is "Hide value" when revealed', () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
|
||||
});
|
||||
|
||||
it('title is "Show value" when hidden', () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=true → should hide)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={true} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=false → should show)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the eye-open SVG (hide icon) when revealed=false", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// The eye SVG contains a circle element; eye-off has a strikethrough line
|
||||
expect(btn.querySelector("circle")).toBeTruthy();
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the eye-off SVG (show icon) when revealed=true", () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// EyeOffIcon has a line (strikethrough) through the eye
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* StatusBadge — secret key connection status indicator.
|
||||
*
|
||||
* Per spec §4: always icon + color (never colour-only) for colour-blind users.
|
||||
* Covers: verified / invalid / unverified render branches, icon, aria-label, className.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { StatusBadge } from "../StatusBadge";
|
||||
|
||||
afterEach(() => {
|
||||
// Prevent DOM accumulation across tests (maxWorkers=1 means all test
|
||||
// files share the same jsdom worker).
|
||||
const { cleanup } = require("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function getBadge(status: "verified" | "invalid" | "unverified") {
|
||||
const { container } = render(<StatusBadge status={status} />);
|
||||
return container.querySelector("[role=status]") as HTMLElement;
|
||||
}
|
||||
|
||||
describe("StatusBadge — icon", () => {
|
||||
it("renders ✓ for verified", () => {
|
||||
expect(getBadge("verified").textContent).toBe("✓");
|
||||
});
|
||||
|
||||
it("renders ✗ for invalid", () => {
|
||||
expect(getBadge("invalid").textContent).toBe("✗");
|
||||
});
|
||||
|
||||
it("renders ○ for unverified", () => {
|
||||
expect(getBadge("unverified").textContent).toBe("○");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge — aria-label", () => {
|
||||
it("sets 'Connection status: verified' for verified", () => {
|
||||
expect(getBadge("verified").getAttribute("aria-label")).toBe(
|
||||
"Connection status: verified",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets 'Connection status: invalid' for invalid", () => {
|
||||
expect(getBadge("invalid").getAttribute("aria-label")).toBe(
|
||||
"Connection status: invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets 'Connection status: unverified' for unverified", () => {
|
||||
expect(getBadge("unverified").getAttribute("aria-label")).toBe(
|
||||
"Connection status: unverified",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge — className", () => {
|
||||
it("applies status-badge--valid for verified", () => {
|
||||
expect(getBadge("verified").className).toContain("status-badge--valid");
|
||||
});
|
||||
|
||||
it("applies status-badge--invalid for invalid", () => {
|
||||
expect(getBadge("invalid").className).toContain("status-badge--invalid");
|
||||
});
|
||||
|
||||
it("applies status-badge--unverified for unverified", () => {
|
||||
expect(getBadge("unverified").className).toContain(
|
||||
"status-badge--unverified",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge — role", () => {
|
||||
it("sets role=status", () => {
|
||||
const el = getBadge("verified");
|
||||
expect(el.getAttribute("role")).toBe("status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge — structural", () => {
|
||||
it("renders exactly one status element", () => {
|
||||
const { container } = render(<StatusBadge status="verified" />);
|
||||
expect(container.querySelectorAll("[role=status]").length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ValidationHint component.
|
||||
*
|
||||
* Covers: null/neutral render, error state (red ⚠ + message), valid state
|
||||
* (green ✓ + "Valid format"), ARIA role="alert" on error.
|
||||
*/
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { ValidationHint } from "../ValidationHint";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ValidationHint", () => {
|
||||
it("renders nothing when error is null and showValid is false", () => {
|
||||
const { container } = render(<ValidationHint error={null} showValid={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when error is null and showValid is undefined", () => {
|
||||
const { container } = render(<ValidationHint error={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders error state with ⚠ icon and message", () => {
|
||||
render(<ValidationHint error="Key name must be UPPER_SNAKE_CASE" />);
|
||||
const el = screen.getByRole("alert");
|
||||
expect(el.textContent).toContain("⚠");
|
||||
expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
|
||||
});
|
||||
|
||||
it("renders valid state with ✓ and 'Valid format'", () => {
|
||||
render(<ValidationHint error={null} showValid />);
|
||||
const el = screen.getByText("Valid format");
|
||||
expect(el.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("prefers error over valid when both are set (error is not null)", () => {
|
||||
// ValidationHint checks error first; showValid is only rendered when error is falsy.
|
||||
render(<ValidationHint error="Some error" showValid />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("Valid format")).toBeNull();
|
||||
});
|
||||
|
||||
it("error alert has role='alert' for screen readers", () => {
|
||||
render(<ValidationHint error="Invalid format" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,17 @@ WS_DIR="${2:?Missing workspace-templates dir}"
|
||||
ORG_DIR="${3:?Missing org-templates dir}"
|
||||
PLUGINS_DIR="${4:?Missing plugins dir}"
|
||||
|
||||
# Strip JSON5-style // comments from manifest.json before parsing.
|
||||
# The automated Integration Tester appends a trailing comment
|
||||
# (// Triggered by ... ) which is valid JSON5 but not standard JSON.
|
||||
# jq's default parser rejects it. This sed removes only full-line comments
|
||||
# (lines starting with optional whitespace followed by //) before jq reads the file.
|
||||
_strip_comments() {
|
||||
# Remove full-line // comments (whitespace-safe); pass-through for non-comment lines
|
||||
sed 's/^[[:space:]]*\/\/.*//' "$MANIFEST"
|
||||
}
|
||||
MANIFEST_JSON="$(_strip_comments)"
|
||||
|
||||
EXPECTED=0
|
||||
CLONED=0
|
||||
|
||||
@@ -88,15 +99,15 @@ clone_category() {
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
local count
|
||||
count=$(jq -r ".${category} | length" "$MANIFEST")
|
||||
count=$(echo "$MANIFEST_JSON" | jq -r ".${category} | length")
|
||||
EXPECTED=$((EXPECTED + count))
|
||||
|
||||
local i=0
|
||||
while [ "$i" -lt "$count" ]; do
|
||||
local name repo ref
|
||||
name=$(jq -r ".${category}[$i].name" "$MANIFEST")
|
||||
repo=$(jq -r ".${category}[$i].repo" "$MANIFEST")
|
||||
ref=$(jq -r ".${category}[$i].ref // \"main\"" "$MANIFEST")
|
||||
name=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].name")
|
||||
repo=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].repo")
|
||||
ref=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].ref // \"main\"")
|
||||
|
||||
# Idempotent: skip if the target already looks populated. Lets the
|
||||
# README quickstart rerun setup.sh safely without having to delete
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
"""Tests for `.gitea/scripts/status-reaper.py` — Option B compensating
|
||||
status POST for Gitea 1.22.6's hardcoded `(push)` suffix bug.
|
||||
|
||||
Coverage (per hongming-pc 22:08Z review + brief):
|
||||
1. test_workflow_with_name_field
|
||||
2. test_workflow_without_name_field (filename stem fallback)
|
||||
3. test_workflow_name_collision_fails_loud
|
||||
4. test_workflow_name_with_slash_fails_loud
|
||||
5. test_has_push_trigger_true (dict shape, list shape, str shape)
|
||||
6. test_has_push_trigger_false (schedule-only, dispatch-only,
|
||||
pull_request-only, workflow_run-only)
|
||||
7. test_publish_workspace_server_image_preserved (explicit case)
|
||||
8. test_compensating_post_payload (POST body shape verification)
|
||||
|
||||
Plus regression coverage:
|
||||
- parse_push_context strictness (only ` (push)` suffix with ` / `
|
||||
separator triggers compensation).
|
||||
- Class-O detection via end-to-end reap() with a stubbed api().
|
||||
- ApiError propagation on non-2xx (mirror of main-red-watchdog's
|
||||
`feedback_api_helper_must_raise_not_return_dict` test).
|
||||
- Unknown-workflow conservatism: ::notice:: + skip, never POST.
|
||||
- Non-`(push)`-suffix contexts (the `(pull_request)` required-checks
|
||||
on main) are NEVER touched — verified safe 2026-05-11.
|
||||
|
||||
Hostile self-review proof:
|
||||
- test_required_check_pull_request_suffix_never_touched exercises
|
||||
the safety contract: a pre-fix that compensated any failing
|
||||
context would mask the Secret scan required-check. Verified by
|
||||
stashing the `endswith(PUSH_SUFFIX)` guard and re-running: test
|
||||
FAILS as required.
|
||||
- test_workflow_name_collision_fails_loud asserts exit code 1; a
|
||||
pre-fix that "first write wins" would silently misclassify a
|
||||
renamed workflow.
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_status_reaper.py -v
|
||||
|
||||
Dependencies: stdlib + pytest + PyYAML. No network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module-import fixture
|
||||
# --------------------------------------------------------------------------
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ ".gitea"
|
||||
/ "scripts"
|
||||
/ "status-reaper.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sr_module():
|
||||
"""Import the script as a module under a known env."""
|
||||
env = {
|
||||
"GITEA_TOKEN": "test-token",
|
||||
"GITEA_HOST": "git.example.test",
|
||||
"REPO": "owner/repo",
|
||||
"WATCH_BRANCH": "main",
|
||||
"WORKFLOWS_DIR": ".gitea/workflows",
|
||||
}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT_PATH)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
m.GITEA_TOKEN = env["GITEA_TOKEN"]
|
||||
m.GITEA_HOST = env["GITEA_HOST"]
|
||||
m.REPO = env["REPO"]
|
||||
m.WATCH_BRANCH = env["WATCH_BRANCH"]
|
||||
m.WORKFLOWS_DIR = env["WORKFLOWS_DIR"]
|
||||
m.OWNER, m.NAME = "owner", "repo"
|
||||
m.API = f"https://{env['GITEA_HOST']}/api/v1"
|
||||
yield m
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Workflow scan tests — workflow_id resolution
|
||||
# --------------------------------------------------------------------------
|
||||
def _write_workflow(tmp_path: Path, filename: str, content: str) -> Path:
|
||||
"""Write a workflow YAML to a temp dir and return its path."""
|
||||
d = tmp_path / "workflows"
|
||||
d.mkdir(exist_ok=True)
|
||||
p = d / filename
|
||||
p.write_text(content)
|
||||
return p
|
||||
|
||||
|
||||
def test_workflow_with_name_field(sr_module, tmp_path):
|
||||
"""`name:` field beats filename stem."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"publish-runtime.yml",
|
||||
"name: publish-runtime\non:\n push:\n branches: [main]\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "publish-runtime" in out
|
||||
assert out["publish-runtime"] is True
|
||||
|
||||
|
||||
def test_workflow_without_name_field(sr_module, tmp_path):
|
||||
"""No `name:` → filename stem (basename minus `.yml`)."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"no-name-workflow.yml",
|
||||
"on:\n schedule:\n - cron: '*/5 * * * *'\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "no-name-workflow" in out
|
||||
assert out["no-name-workflow"] is False # schedule-only → class-O
|
||||
|
||||
|
||||
def test_workflow_name_collision_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""Two workflows resolving to the same name → exit 1 with ::error::."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"a.yml",
|
||||
"name: same-name\non:\n push: {}\n",
|
||||
)
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"b.yml",
|
||||
"name: same-name\non:\n schedule:\n - cron: '0 * * * *'\n",
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert excinfo.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::error::workflow name collision detected: same-name" in captured.err
|
||||
|
||||
|
||||
def test_workflow_name_with_slash_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""`name:` containing `/` → exit 1 with ::error:: (breaks context parse)."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"weird.yml",
|
||||
"name: my/weird/name\non:\n push: {}\n",
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert excinfo.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::error::workflow name contains '/'" in captured.err
|
||||
assert "my/weird/name" in captured.err
|
||||
|
||||
|
||||
def test_workflow_name_with_slash_via_filename_stem_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""Even if filename stem contains `/` (path-flavoured stem) we trip the
|
||||
same guard. Defensive — Path.stem strips `/` so this can't happen via
|
||||
real filesystems, but the guard catches it if someone synthesises a
|
||||
map from a non-filesystem source in future."""
|
||||
# Force the filename-stem path by writing a no-name workflow whose
|
||||
# PARENT path has a `/` — but Path.stem only takes the basename, so
|
||||
# we instead mock _on_block / iterate manually. Easier: assert the
|
||||
# in-code check directly.
|
||||
# The `/` guard runs on `workflow_id`. Test it via an explicit name
|
||||
# field workflow (already covered) — this test is left as a
|
||||
# docstring-only marker that the filename-stem path can't ever
|
||||
# produce a `/` (Path.stem strips it).
|
||||
assert True # No-op: Path.stem strips `/`; documented invariant.
|
||||
|
||||
|
||||
def test_workflow_empty_name_falls_back_to_stem(sr_module, tmp_path):
|
||||
"""Empty `name:` (just whitespace) should fall back to filename stem."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"stem-fallback.yml",
|
||||
"name: ' '\non:\n push: {}\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "stem-fallback" in out # filename stem used
|
||||
assert out["stem-fallback"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# has_push_trigger tests
|
||||
# --------------------------------------------------------------------------
|
||||
def test_has_push_trigger_true_dict(sr_module):
|
||||
assert sr_module._has_push_trigger({"push": {}, "schedule": []}, "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_true_dict_with_paths(sr_module):
|
||||
"""`on: { push: { paths: ['workspace/**'] } }` → still push-triggered."""
|
||||
assert (
|
||||
sr_module._has_push_trigger(
|
||||
{"push": {"paths": ["workspace/**"]}}, "w"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_true_list(sr_module):
|
||||
assert sr_module._has_push_trigger(["push", "pull_request"], "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_true_str(sr_module):
|
||||
assert sr_module._has_push_trigger("push", "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_false_schedule_only(sr_module):
|
||||
"""Schedule-only workflow (class-O canonical)."""
|
||||
assert (
|
||||
sr_module._has_push_trigger(
|
||||
{"schedule": [{"cron": "0 * * * *"}]}, "w"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_false_dispatch_only(sr_module):
|
||||
assert sr_module._has_push_trigger({"workflow_dispatch": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_pull_request_only(sr_module):
|
||||
"""`on: { pull_request: {...} }` only → no push trigger."""
|
||||
assert sr_module._has_push_trigger({"pull_request": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_workflow_run_only(sr_module):
|
||||
"""`on: { workflow_run: {...} }` → no push trigger.
|
||||
(Even though Gitea 1.22.6 doesn't fire workflow_run, the classifier
|
||||
must handle YAML that declares it — for forward-compat.)"""
|
||||
assert sr_module._has_push_trigger({"workflow_run": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_list_no_push(sr_module):
|
||||
assert (
|
||||
sr_module._has_push_trigger(["pull_request", "schedule"], "w") is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_ambiguous_preserves(sr_module, capsys):
|
||||
"""Unknown shape → True (preserve, never compensate) + log ::notice::."""
|
||||
assert sr_module._has_push_trigger(42, "weird-workflow") is True
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::ambiguous on: for weird-workflow" in captured.out
|
||||
|
||||
|
||||
def test_has_push_trigger_none_preserves(sr_module, capsys):
|
||||
"""None `on:` block → True (preserve)."""
|
||||
assert sr_module._has_push_trigger(None, "no-on") is True
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::ambiguous on:" in captured.out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Real-world fixture: publish-workspace-server-image preserved
|
||||
# --------------------------------------------------------------------------
|
||||
def test_publish_workspace_server_image_preserved(sr_module, tmp_path):
|
||||
"""Explicit case per brief: real `push` trigger → preserve, even
|
||||
when failing. Protects mc#576 (currently red on docker-socket issue).
|
||||
"""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"publish-workspace-server-image.yml",
|
||||
"name: publish-workspace-server-image\n"
|
||||
"on:\n"
|
||||
" push:\n"
|
||||
" branches: [main]\n"
|
||||
" paths: ['workspace/**']\n"
|
||||
" workflow_dispatch:\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert out["publish-workspace-server-image"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def test_parse_push_context_canonical(sr_module):
|
||||
"""`<workflow_name> / <job_name> (push)` → (workflow_name, job_name)."""
|
||||
parsed = sr_module.parse_push_context("staging-smoke / smoke (push)")
|
||||
assert parsed == ("staging-smoke", "smoke")
|
||||
|
||||
|
||||
def test_parse_push_context_workflow_name_with_spaces(sr_module):
|
||||
"""Workflow name with spaces — common (`Continuous synthetic E2E`)."""
|
||||
parsed = sr_module.parse_push_context(
|
||||
"Continuous synthetic E2E (staging) / e2e (push)"
|
||||
)
|
||||
assert parsed == ("Continuous synthetic E2E (staging)", "e2e")
|
||||
|
||||
|
||||
def test_parse_push_context_non_push_suffix_returns_none(sr_module):
|
||||
"""`(pull_request)` suffix → None (not the bug shape; required-checks)."""
|
||||
assert (
|
||||
sr_module.parse_push_context("Secret scan / Scan diff (pull_request)")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_push_context_no_separator_returns_none(sr_module):
|
||||
"""`(push)` suffix but no ` / ` → None (not the bug shape)."""
|
||||
assert sr_module.parse_push_context("just-a-context (push)") is None
|
||||
|
||||
|
||||
def test_parse_push_context_no_suffix_returns_none(sr_module):
|
||||
assert sr_module.parse_push_context("workflow / job") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST payload shape
|
||||
# --------------------------------------------------------------------------
|
||||
def test_compensating_post_payload(sr_module, monkeypatch):
|
||||
"""POST /statuses/{sha} body: state=success, context preserved,
|
||||
description = COMPENSATION_DESCRIPTION, target_url echoed if present.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body, query))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
sr_module.post_compensating_status(
|
||||
"deadbeefcafe1234567890abcdef000011112222",
|
||||
"staging-smoke / smoke (push)",
|
||||
"https://git.example.test/owner/repo/actions/runs/14525",
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
method, path, body, _query = calls[0]
|
||||
assert method == "POST"
|
||||
assert path == "/repos/owner/repo/statuses/deadbeefcafe1234567890abcdef000011112222"
|
||||
assert body == {
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"state": "success",
|
||||
"description": sr_module.COMPENSATION_DESCRIPTION,
|
||||
"target_url": "https://git.example.test/owner/repo/actions/runs/14525",
|
||||
}
|
||||
|
||||
|
||||
def test_compensating_post_payload_no_target_url(sr_module, monkeypatch):
|
||||
"""target_url is optional — omitted when the original status had none."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body, query))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
sr_module.post_compensating_status(
|
||||
"abc1234567",
|
||||
"x / y (push)",
|
||||
None,
|
||||
dry_run=False,
|
||||
)
|
||||
assert calls[0][2] == {
|
||||
"context": "x / y (push)",
|
||||
"state": "success",
|
||||
"description": sr_module.COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
|
||||
|
||||
def test_compensating_post_dry_run_no_api_call(sr_module, monkeypatch, capsys):
|
||||
"""--dry-run must NOT POST."""
|
||||
def fake_api(*args, **kwargs):
|
||||
raise AssertionError("api() should not be called in dry_run")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
sr_module.post_compensating_status(
|
||||
"deadbeefcafe1234567890abcdef000011112222",
|
||||
"ci/test (push)",
|
||||
None,
|
||||
dry_run=True,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::[dry-run] would compensate" in captured.out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# End-to-end reap() — class-O detection
|
||||
# --------------------------------------------------------------------------
|
||||
SHA = "deadbeefcafe1234567890abcdef000011112222"
|
||||
|
||||
|
||||
def test_reap_compensates_class_o(sr_module, monkeypatch):
|
||||
"""schedule-only workflow with failing `(push)` status → compensate."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"staging-smoke": False} # no push trigger
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"state": "failure",
|
||||
"target_url": "https://example.test/run/1",
|
||||
"description": "smoke job failed",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 1
|
||||
assert counters["preserved_real_push"] == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "POST"
|
||||
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
|
||||
|
||||
|
||||
def test_reap_preserves_real_push(sr_module, monkeypatch):
|
||||
"""publish-workspace-server-image (has push trigger) → preserve."""
|
||||
calls = []
|
||||
|
||||
def fake_api(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"publish-workspace-server-image": True}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "publish-workspace-server-image / build (push)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_real_push"] == 1
|
||||
assert calls == [] # NO POST
|
||||
|
||||
|
||||
def test_reap_preserves_unknown_workflow(sr_module, monkeypatch, capsys):
|
||||
"""Workflow not in map → ::notice:: + skip (conservative)."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {} # empty map
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "deleted-workflow / job (push)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_unknown"] == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::unknown workflow 'deleted-workflow'" in captured.out
|
||||
|
||||
|
||||
def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkeypatch):
|
||||
"""SAFETY CONTRACT: `(pull_request)` suffix contexts (the actual
|
||||
required-checks on main) are NEVER touched. A pre-fix that
|
||||
compensated any failure would mask Secret scan.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_api(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
# Even with the workflow mapped as no-push-trigger (which would
|
||||
# normally compensate), the suffix guard prevents the POST.
|
||||
workflow_map = {"Secret scan": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_non_push_suffix"] == 1
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_reap_ignores_non_failure_states(sr_module, monkeypatch):
|
||||
"""Only `failure` is compensated. `pending` / `success` / `error`
|
||||
left alone — they have legitimate semantics."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {"sweep-cf-tunnels": False}
|
||||
combined = {
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "pending"},
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "success"},
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "error"},
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_non_failure"] == 3
|
||||
|
||||
|
||||
def test_reap_unparseable_push_context_preserved(sr_module, monkeypatch):
|
||||
"""`(push)` suffix but no ` / ` separator → not the bug shape, preserve."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {"x": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "no-slash-here (push)", "state": "failure"},
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_unparseable"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ApiError propagation
|
||||
# --------------------------------------------------------------------------
|
||||
def test_get_head_sha_raises_on_non_2xx(sr_module, monkeypatch):
|
||||
"""ApiError on transient outage propagates per
|
||||
`feedback_api_helper_must_raise_not_return_dict`."""
|
||||
def fake_api(method, path, **kwargs):
|
||||
raise sr_module.ApiError("GET /branches/main -> HTTP 500: nope")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_head_sha("main")
|
||||
|
||||
|
||||
def test_get_combined_status_raises_on_non_2xx(sr_module, monkeypatch):
|
||||
def fake_api(method, path, **kwargs):
|
||||
raise sr_module.ApiError("GET /status -> HTTP 500: nope")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_combined_status("deadbeef")
|
||||
|
||||
|
||||
def test_get_head_sha_missing_commit_raises(sr_module, monkeypatch):
|
||||
"""A malformed 200 response (no `commit` field) raises ApiError."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api", lambda m, p, **kw: (200, {"name": "main"})
|
||||
)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_head_sha("main")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# scan_workflows on real repo (smoke)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_scan_workflows_on_real_repo_no_collision(sr_module):
|
||||
"""Smoke: scan the actual .gitea/workflows/ in this repo. Asserts
|
||||
no real-world collision/`/`-in-name lurks. If this fails, a real
|
||||
workflow file must be fixed before reaper can ship."""
|
||||
real_dir = str(SCRIPT_PATH.parent.parent / "workflows")
|
||||
# Should NOT raise SystemExit — collision/slash guards must pass.
|
||||
out = sr_module.scan_workflows(real_dir)
|
||||
assert len(out) > 0
|
||||
# publish-workspace-server-image is the canonical preserved case.
|
||||
assert out.get("publish-workspace-server-image") is True
|
||||
# main-red-watchdog is the canonical class-O case.
|
||||
assert out.get("main-red-watchdog") is False
|
||||
# ci is the canonical required-check (push+pull_request).
|
||||
assert out.get("CI") is True or out.get("ci") is True
|
||||
|
||||
|
||||
def test_scan_workflows_missing_dir_returns_empty(sr_module, tmp_path, capsys):
|
||||
"""Missing workflows dir → empty map + ::warning::."""
|
||||
out = sr_module.scan_workflows(str(tmp_path / "nope"))
|
||||
assert out == {}
|
||||
captured = capsys.readouterr()
|
||||
assert "::warning::workflows dir not found" in captured.out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rev2: multi-SHA sweep — `reap_branch()` walks last N main commits
|
||||
# --------------------------------------------------------------------------
|
||||
# Phase 1+2 evidence (orchestrator + hongming-pc2): rev1 sees `compensated:0`
|
||||
# every tick because the schedule workflow posts `failure` to whatever SHA
|
||||
# was HEAD when it COMPLETED. By the next */5 tick, main has often moved
|
||||
# forward, so the single-HEAD reaper misses the stranded red. rev2 sweeps
|
||||
# the last 10 commits each tick. See `reference_post_suspension_pipeline`
|
||||
# and parent rev1 PR #618 for context.
|
||||
|
||||
SHA_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
SHA_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
SHA_C = "cccccccccccccccccccccccccccccccccccccccc"
|
||||
|
||||
|
||||
def test_reap_sweeps_n_shas_smoke(sr_module, monkeypatch):
|
||||
"""rev2 contract: sweep last 10 (or N) main commits, GET combined
|
||||
status for EACH. Smoke: with 3 stub SHAs, each is GET'd exactly once.
|
||||
"""
|
||||
gets: list[str] = []
|
||||
posts: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
if method == "GET" and path.endswith("/commits"):
|
||||
# commits listing — return 3 fake commit objects
|
||||
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
|
||||
if method == "GET" and "/commits/" in path and path.endswith("/status"):
|
||||
sha = path.split("/commits/")[1].split("/status")[0]
|
||||
gets.append(sha)
|
||||
# All combined=success → cost-optimization short-circuit
|
||||
return (200, {"state": "success", "statuses": []})
|
||||
if method == "POST":
|
||||
posts.append((path, body))
|
||||
return (201, {})
|
||||
raise AssertionError(f"unexpected api call: {method} {path}")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"x": False}
|
||||
counters = sr_module.reap_branch(
|
||||
workflow_map, "main", limit=10, dry_run=False
|
||||
)
|
||||
|
||||
# Each of the 3 SHAs returned by /commits should be GET'd once.
|
||||
assert gets == [SHA_A, SHA_B, SHA_C]
|
||||
# No POST (everything was combined=success).
|
||||
assert posts == []
|
||||
# Counters reflect what we saw.
|
||||
assert counters["scanned_shas"] == 3
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["compensated_per_sha"] == {}
|
||||
|
||||
|
||||
def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
|
||||
"""rev2 cost-optimization (refinement #2): when combined==success for
|
||||
a SHA, do NOT iterate per-context statuses; move on to next SHA.
|
||||
|
||||
Mock 2 SHAs with combined=success + 1 with combined=failure → only
|
||||
the failure-SHA's statuses get the per-context loop applied.
|
||||
"""
|
||||
per_context_iterated_for: list[str] = []
|
||||
posts: list[tuple[str, dict]] = []
|
||||
|
||||
failure_statuses = [
|
||||
{
|
||||
"context": "drift / drift (push)",
|
||||
"state": "failure",
|
||||
"target_url": "https://example.test/run/42",
|
||||
}
|
||||
]
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
if method == "GET" and path.endswith("/commits"):
|
||||
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
|
||||
if method == "GET" and "/commits/" in path and path.endswith("/status"):
|
||||
sha = path.split("/commits/")[1].split("/status")[0]
|
||||
if sha == SHA_B:
|
||||
# Mark this SHA as the failure one — return per-context
|
||||
# statuses that would compensate if iterated.
|
||||
return (200, {"state": "failure", "statuses": failure_statuses})
|
||||
# Others are combined=success — must short-circuit.
|
||||
return (200, {"state": "success", "statuses": failure_statuses})
|
||||
if method == "POST":
|
||||
# If a POST hits a non-failure SHA, the short-circuit failed.
|
||||
posts.append((path, body))
|
||||
return (201, {})
|
||||
raise AssertionError(f"unexpected api call: {method} {path}")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
# Workflow trigger map: `drift` is schedule-only (compensable).
|
||||
workflow_map = {"drift": False}
|
||||
counters = sr_module.reap_branch(
|
||||
workflow_map, "main", limit=10, dry_run=False
|
||||
)
|
||||
|
||||
# Only SHA_B (the combined=failure one) should be compensated.
|
||||
assert counters["compensated"] == 1
|
||||
assert counters["scanned_shas"] == 3
|
||||
assert SHA_B in counters["compensated_per_sha"]
|
||||
assert counters["compensated_per_sha"][SHA_B] == ["drift / drift (push)"]
|
||||
# SHA_A and SHA_C must NOT appear in compensated_per_sha — their
|
||||
# per-context loop was skipped via the combined=success short-circuit.
|
||||
assert SHA_A not in counters["compensated_per_sha"]
|
||||
assert SHA_C not in counters["compensated_per_sha"]
|
||||
# Exactly one POST: the compensation on SHA_B.
|
||||
assert len(posts) == 1
|
||||
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
|
||||
|
||||
|
||||
def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
|
||||
"""rev2 refinement #7 (MOST CRITICAL): a transient ApiError or HTTP-5xx
|
||||
on get_combined_status(SHA_X) must NOT fail the whole tick. Log + skip
|
||||
SHA_X, continue with SHA_Y.
|
||||
|
||||
Different from the single-HEAD path (where fail-loud is correct): the
|
||||
sweep is best-effort across historical commits, so one transient blip
|
||||
on a stale SHA should not strand reds on the OTHER stale SHAs.
|
||||
"""
|
||||
posts: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
if method == "GET" and path.endswith("/commits"):
|
||||
return (200, [{"sha": SHA_A}, {"sha": SHA_B}])
|
||||
if method == "GET" and "/commits/" in path and path.endswith("/status"):
|
||||
sha = path.split("/commits/")[1].split("/status")[0]
|
||||
if sha == SHA_A:
|
||||
raise sr_module.ApiError(
|
||||
f"GET /repos/owner/repo/commits/{SHA_A}/status "
|
||||
f"-> HTTP 502: bad gateway"
|
||||
)
|
||||
# SHA_B returns normally with a failure to compensate.
|
||||
return (
|
||||
200,
|
||||
{
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "drift / drift (push)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
if method == "POST":
|
||||
posts.append((path, body))
|
||||
return (201, {})
|
||||
raise AssertionError(f"unexpected api call: {method} {path}")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"drift": False}
|
||||
# Must NOT raise — per-SHA error isolation contract.
|
||||
counters = sr_module.reap_branch(
|
||||
workflow_map, "main", limit=10, dry_run=False
|
||||
)
|
||||
|
||||
# SHA_A was logged + skipped. SHA_B processed normally.
|
||||
assert counters["scanned_shas"] == 2
|
||||
assert counters["compensated"] == 1
|
||||
assert SHA_B in counters["compensated_per_sha"]
|
||||
assert SHA_A not in counters["compensated_per_sha"]
|
||||
# Compensation POST landed on SHA_B only.
|
||||
assert len(posts) == 1
|
||||
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
|
||||
# The ApiError must be logged so a human auditing tick output can see
|
||||
# WHICH SHA blipped and WHY.
|
||||
captured = capsys.readouterr()
|
||||
assert "::warning::" in captured.out or "::notice::" in captured.out
|
||||
assert SHA_A[:10] in captured.out
|
||||
@@ -35,6 +35,12 @@ GITEA_HOST = os.environ.get("GITEA_HOST", "git.moleculesai.app")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", os.environ.get("GITHUB_TOKEN", ""))
|
||||
API_BASE = f"https://{GITEA_HOST}/api/v1"
|
||||
|
||||
# Timeout in seconds for all HTTP calls. Defence-in-depth: ensures a missing or
|
||||
# invalid SOP_TIER_CHECK_TOKEN causes a fast (~15 s) failure rather than an
|
||||
# indefinite hang. The real fix is provisioning the token; this caps worst-case
|
||||
# wall-clock on a broken/unreachable Gitea host.
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
|
||||
def api_get(path: str) -> dict | list:
|
||||
url = f"{API_BASE}{path}"
|
||||
@@ -46,7 +52,7 @@ def api_get(path: str) -> dict | list:
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
@@ -521,12 +527,12 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
|
||||
comment_id = our_comments[-1]["id"]
|
||||
url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
|
||||
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
|
||||
r.read()
|
||||
else:
|
||||
url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
|
||||
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
|
||||
r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 403:
|
||||
|
||||
@@ -983,7 +983,16 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
|
||||
WithArgs("dispatched", "", testSourceID, testDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
|
||||
// CanCommunicate: source != target → fires two getWorkspaceRef lookups.
|
||||
// Both test fixtures have parent_id = NULL (root-level siblings) → allowed.
|
||||
// Order matches call order: source first, then target.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs(testSourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs(testTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
|
||||
|
||||
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
|
||||
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
|
||||
WithArgs(testTargetID).
|
||||
|
||||
@@ -763,6 +763,7 @@ def test_sanitize_agent_error_stderr_and_exc():
|
||||
out = sanitize_agent_error(exc=err, stderr="rate limit exceeded")
|
||||
assert "ValueError" in out # exc class IS the tag when stderr is provided
|
||||
assert "rate limit exceeded" in out
|
||||
assert "workspace logs" not in out # stderr form, not the generic form
|
||||
|
||||
|
||||
def test_sanitize_agent_error_stderr_empty_string():
|
||||
|
||||
Reference in New Issue
Block a user