Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de6fe24755 | |||
| 389d18fa59 | |||
| 20241de570 | |||
| 5738f53ee8 | |||
| 0b47f9516d | |||
| 2a476c3bbb | |||
| 7888f96f45 | |||
| 4e92e46182 | |||
| f417c1a870 | |||
| 8628d5cd2d | |||
| 4262c0a3db | |||
| 1dd6697031 | |||
| 5e6c490b19 | |||
| cdb0b0401a | |||
| 3297d16093 | |||
| e0e5dd911f | |||
| a50f51eb8f | |||
| e11f1f3c06 | |||
| 126edf74c1 | |||
| 927663d5bf | |||
| a3eee58dbd | |||
| 9cf997597d | |||
| b713491eda | |||
| bbdb753e82 | |||
| 40df07e94d | |||
| 5efbbd9fa8 | |||
| 3d669b35de | |||
| aea1223b2e | |||
| e6d50ff5ba | |||
| f04e475eab | |||
| 0e34816def | |||
| 60c28ed872 | |||
| 607ab35d7c | |||
| 4b76fe43b1 | |||
| 0afbf3e6d4 | |||
| 57886b714c | |||
| 283fa10415 | |||
| ae75557e6b | |||
| 21cbad5867 | |||
| 79e9e51865 | |||
| 95deb8b98e | |||
| 829b32b867 | |||
| 7709c6bd54 | |||
| e16abf15de | |||
| 6448b38dd9 | |||
| c446329aad | |||
| 51e889f2f3 | |||
| 6a3e854329 | |||
| b94218e5c1 | |||
| 3968bdd92a | |||
| 5a79ccde4c | |||
| 783c9dc6a3 | |||
| 689d454920 | |||
| bb1be0a277 | |||
| 466c510547 | |||
| 1bfff48e9c | |||
| aacf191b6a | |||
| 9c43f6a6e3 | |||
| 1db69d520b | |||
| ca80e3cc91 | |||
| 6cbf880b04 |
@@ -203,12 +203,17 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
|
||||
|
||||
def ci_job_names(ci_doc: dict) -> set[str]:
|
||||
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
|
||||
whose `if:` gates on `github.event_name` (those are event-scoped
|
||||
and can legitimately be `skipped` for a given trigger; if we
|
||||
required them under the sentinel `needs:`, every PR-only job
|
||||
whose `if:` gates on `github.event_name` or `github.ref` (those are
|
||||
event-scoped and can legitimately be `skipped` for a given trigger;
|
||||
if we required them under the sentinel `needs:`, every PR-only job
|
||||
would be `skipped` on push and the sentinel would interpret
|
||||
`skipped != success` as failure). RFC §4 spec.
|
||||
|
||||
`github.ref` is the companion gate for jobs that run only on direct
|
||||
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
|
||||
These never execute in a PR context, so flagging them as missing
|
||||
from `all-required.needs:` is a false positive (mc#958 / mc#959).
|
||||
|
||||
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
|
||||
(typos in needs) — see `ci_jobs_all` for that."""
|
||||
jobs = ci_doc.get("jobs")
|
||||
@@ -221,7 +226,9 @@ def ci_job_names(ci_doc: dict) -> set[str]:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
gate = v.get("if")
|
||||
if isinstance(gate, str) and "github.event_name" in gate:
|
||||
if isinstance(gate, str) and (
|
||||
"github.event_name" in gate or "github.ref" in gate
|
||||
):
|
||||
continue
|
||||
names.add(k)
|
||||
return names
|
||||
|
||||
@@ -47,6 +47,15 @@ REQUIRED_CONTEXTS_RAW = _env(
|
||||
"sop-checklist / all-items-acked (pull_request)"
|
||||
),
|
||||
)
|
||||
# Required contexts for push (main/staging) runs. The push CI uses the same
|
||||
# aggregator names with " (push)" suffix. Checking these explicitly instead of
|
||||
# the combined state avoids false-pause when non-blocking jobs (e.g. Platform
|
||||
# Go with continue-on-error: true due to mc#774) have failed — their failures
|
||||
# pollute the combined state but do not block merges.
|
||||
PUSH_REQUIRED_CONTEXTS_RAW = _env(
|
||||
"PUSH_REQUIRED_CONTEXTS",
|
||||
default="CI / all-required (push)",
|
||||
)
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
@@ -118,16 +127,24 @@ def required_contexts(raw: str) -> list[str]:
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def push_required_contexts() -> list[str]:
|
||||
"""Required contexts for push (branch) CI runs. See PUSH_REQUIRED_CONTEXTS_RAW."""
|
||||
return required_contexts(PUSH_REQUIRED_CONTEXTS_RAW)
|
||||
|
||||
|
||||
def status_state(status: dict) -> str:
|
||||
return str(status.get("status") or status.get("state") or "").lower()
|
||||
|
||||
|
||||
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||
# Gitea /statuses endpoint returns entries in ascending id order (oldest
|
||||
# first). We need the LAST occurrence of each context, so iterate in
|
||||
# reverse to prefer newer entries.
|
||||
latest: dict[str, dict] = {}
|
||||
for status in statuses:
|
||||
for status in reversed(statuses):
|
||||
context = status.get("context")
|
||||
if isinstance(context, str) and context not in latest:
|
||||
latest[context] = status
|
||||
if isinstance(context, str):
|
||||
latest[context] = status # overwrite: reverse order → newest wins
|
||||
return latest
|
||||
|
||||
|
||||
@@ -193,16 +210,23 @@ def evaluate_merge_readiness(
|
||||
required_contexts: list[str],
|
||||
pr_has_current_base: bool,
|
||||
) -> MergeDecision:
|
||||
main_state = str(main_status.get("state") or "").lower()
|
||||
if main_state != "success":
|
||||
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
|
||||
# Check push-required contexts explicitly instead of combined state.
|
||||
# Combined state can be "failure" due to non-blocking jobs
|
||||
# (continue-on-error: true) that don't actually gate merges.
|
||||
# CI / all-required (push) is the authoritative gate — it respects
|
||||
# continue-on-error and correctly aggregates all blocking failures.
|
||||
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
|
||||
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
|
||||
if not main_ok:
|
||||
return MergeDecision(False, "pause", "main required contexts not green: " + ", ".join(main_bad))
|
||||
if not pr_has_current_base:
|
||||
return MergeDecision(False, "update", "PR head does not contain current main")
|
||||
|
||||
pr_state = str(pr_status.get("state") or "").lower()
|
||||
if pr_state != "success":
|
||||
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
|
||||
|
||||
# Check explicit required contexts instead of combined state. Combined state
|
||||
# can be "failure" due to non-blocking jobs with continue-on-error: true
|
||||
# (e.g. publish-runtime-autobump/pr-validate, qa-review on stale tokens).
|
||||
# The required_contexts list is the authoritative gate — it includes only
|
||||
# the checks that actually block merges.
|
||||
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
|
||||
if not ok:
|
||||
@@ -220,10 +244,37 @@ def get_branch_head(branch: str) -> str:
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
"""Combined status + all individual statuses for `sha`.
|
||||
|
||||
The /status endpoint caps the `statuses` array at 30 entries (Gitea
|
||||
default page size), so we fetch the full list via /statuses with a
|
||||
higher limit. The combined `state` still comes from /status.
|
||||
"""
|
||||
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(combined, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
return body
|
||||
# Fetch full statuses list; 200 covers >99% of real-world runs.
|
||||
# The list is ordered ascending by id (oldest first) — callers must
|
||||
# iterate in reverse to get the newest entry per context.
|
||||
# Best-effort: large repos (main with 550+ statuses) may time out.
|
||||
# On timeout, fall back to the statuses[] already in the combined
|
||||
# response (usually 30 entries — enough for most PRs, enough for
|
||||
# main's early push-required contexts).
|
||||
try:
|
||||
_, all_statuses = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
||||
query={"limit": "50"},
|
||||
)
|
||||
if isinstance(all_statuses, list):
|
||||
combined["statuses"] = all_statuses
|
||||
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
# URLError covers network-level failures (DNS, refused, timeout).
|
||||
# TimeoutError and OSError cover socket-level timeouts.
|
||||
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
|
||||
# Fall back to the statuses[] already in the combined response.
|
||||
pass
|
||||
return combined
|
||||
|
||||
|
||||
def list_queued_issues() -> list[dict]:
|
||||
@@ -294,8 +345,12 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
|
||||
main_sha = get_branch_head(WATCH_BRANCH)
|
||||
main_status = get_combined_status(main_sha)
|
||||
if str(main_status.get("state") or "").lower() != "success":
|
||||
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
|
||||
# Check push-required contexts explicitly instead of combined state.
|
||||
# See evaluate_merge_readiness for rationale.
|
||||
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
|
||||
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
|
||||
if not main_ok:
|
||||
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
|
||||
return 0
|
||||
|
||||
issue = choose_next_queued_issue(
|
||||
@@ -362,7 +417,21 @@ def main() -> int:
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
_require_runtime_env()
|
||||
return process_once(dry_run=args.dry_run)
|
||||
try:
|
||||
return process_once(dry_run=args.dry_run)
|
||||
except ApiError as exc:
|
||||
# API errors (401/403/404/500) are transient for a queue tick —
|
||||
# log and exit 0 so the workflow is not marked failed and the next
|
||||
# tick can retry. Returning non-zero would permanently fail the
|
||||
# workflow run, blocking future ticks.
|
||||
sys.stderr.write(f"::error::queue API error: {exc}\n")
|
||||
return 0
|
||||
except urllib.error.URLError as exc:
|
||||
sys.stderr.write(f"::error::queue network error: {exc}\n")
|
||||
return 0
|
||||
except TimeoutError as exc:
|
||||
sys.stderr.write(f"::error::queue timeout: {exc}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -85,7 +85,10 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
|
||||
|
||||
def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
||||
required = ["CI / all-required (pull_request)"]
|
||||
main_status = {"state": "success", "statuses": []}
|
||||
main_status = {
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
||||
}
|
||||
pr_status = {
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
|
||||
@@ -104,7 +107,10 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
||||
|
||||
def test_merge_decision_updates_stale_pr_before_merge():
|
||||
decision = mq.evaluate_merge_readiness(
|
||||
main_status={"state": "success", "statuses": []},
|
||||
main_status={
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
||||
},
|
||||
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
|
||||
required_contexts=["CI / all-required (pull_request)"],
|
||||
pr_has_current_base=False,
|
||||
|
||||
+22
-11
@@ -146,6 +146,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -190,7 +194,11 @@ jobs:
|
||||
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 ./...
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Per-file coverage report
|
||||
@@ -296,6 +304,7 @@ jobs:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
defaults:
|
||||
@@ -394,12 +403,13 @@ jobs:
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
|
||||
# ci_job_names() detects this as github.ref-gated and skips it from F1.
|
||||
# The step-level exit 0 handles the "not main push" case; the job-level
|
||||
# `if:` makes the gating explicit so the drift script sees it.
|
||||
# continue-on-error removed (was mc#774 mask): step exits 0 when not applicable.
|
||||
needs: [changes, canvas-build]
|
||||
# Keep the job itself always runnable. Gitea 1.22.6 leaves job-level
|
||||
# event/ref `if:` gates as pending on PRs, which blocks the combined
|
||||
# status even though this reminder is intentionally non-required.
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
steps:
|
||||
- name: Write deploy reminder to step summary
|
||||
env:
|
||||
@@ -562,11 +572,11 @@ jobs:
|
||||
# hourly if this list diverges from status_check_contexts or from
|
||||
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||
#
|
||||
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
|
||||
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
|
||||
# Including it in all-required.needs causes all-required to hang on
|
||||
# every CI-only PR. Keep it runnable on PRs via its own
|
||||
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
|
||||
# canvas-deploy-reminder IS now included in all-required.needs (mc#958 root-fix):
|
||||
# added job-level `if: github.ref == 'refs/heads/main'` so ci-required-drift.py's
|
||||
# ci_job_names() detects it as github.ref-gated and skips it from F1.
|
||||
# The step-level `if: ... || REF_NAME != refs/heads/main` exits 0 when not main,
|
||||
# so the job succeeds (not skipped) on non-main pushes — sentinel treats as green.
|
||||
#
|
||||
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||
@@ -586,6 +596,7 @@ jobs:
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
- canvas-deploy-reminder
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Assert every required dependency succeeded
|
||||
|
||||
@@ -48,4 +48,9 @@ jobs:
|
||||
REQUIRED_CONTEXTS: >-
|
||||
CI / all-required (pull_request),
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
# Push-side required contexts. Checking CI / all-required (push)
|
||||
# explicitly instead of the combined state avoids false-pause when
|
||||
# non-blocking jobs (continue-on-error: true) have failed — those
|
||||
# failures pollute combined state but do not gate merges.
|
||||
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
trigger
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Org-wide broadcast banner.
|
||||
*
|
||||
* Rendered at the top of the canvas (below the toolbar) whenever the store
|
||||
* holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
|
||||
* - sender name (workspace that issued the broadcast)
|
||||
* - the message text
|
||||
* - a dismiss button
|
||||
*
|
||||
* Dismissing an entry removes it from the store via consumeBroadcastMessages.
|
||||
* The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
|
||||
* on page refresh since they are not persisted server-side; this is intentional
|
||||
* (the platform's activity log already provides the audit trail).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
|
||||
const consumeBroadcastMessages = useCanvasStore((s) => s.consumeBroadcastMessages);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void consumeBroadcastMessages();
|
||||
}, [consumeBroadcastMessages]);
|
||||
|
||||
if (broadcastMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
|
||||
{broadcastMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Megaphone icon */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-300"
|
||||
>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-blue-300 font-semibold">
|
||||
Broadcast from{" "}
|
||||
<span className="text-blue-100">{msg.sender}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss broadcast"
|
||||
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@@ -367,6 +368,7 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
@@ -344,7 +344,7 @@ function ProviderPickerModal({
|
||||
// wrapper's bounds instead of the viewport.
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
@@ -616,7 +616,7 @@ function AllKeysModal({
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
|
||||
@@ -62,11 +62,11 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
}
|
||||
setTheme(OPTIONS[next].value);
|
||||
// Move focus to the new button so arrow-key navigation is continuous.
|
||||
// Use direct-child query to scope strictly to this radiogroup's buttons
|
||||
// and avoid accidentally focusing unrelated [role=radio] elements
|
||||
// Query is already scoped to radiogroup so no child-combinator needed;
|
||||
// avoids accidentally focusing unrelated [role=radio] elements
|
||||
// elsewhere in the DOM (e.g. React Flow canvas nodes).
|
||||
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
|
||||
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("> [role=radio]");
|
||||
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("[role=radio]");
|
||||
btns?.[next]?.focus();
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -13,17 +13,20 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||
* rendered as full cards inside this one via React Flow's native parentId,
|
||||
* so we don't need to subscribe to the actual child list here. */
|
||||
* so we don't need to subscribe to the actual child list here.
|
||||
* Selecting `nodes` stably avoids a new selector reference on every store
|
||||
* update (React error #185 / Zustand + React 19 Object.is strictness). */
|
||||
function useDescendantCount(nodeId: string): number {
|
||||
return useCanvasStore(
|
||||
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
|
||||
);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
|
||||
}
|
||||
|
||||
/** Boolean flag used to drive min-size and NodeResizer dimensions.
|
||||
* Selecting `nodes` stably avoids re-render loops (same issue as
|
||||
* useDescendantCount). */
|
||||
function useHasChildren(nodeId: string): boolean {
|
||||
return useCanvasStore(
|
||||
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
|
||||
);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
|
||||
}
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
|
||||
@@ -73,6 +73,8 @@ const mockStoreState = {
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -91,6 +91,8 @@ const mockStoreState = {
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -24,16 +24,20 @@ import {
|
||||
*/
|
||||
export function DropTargetBadge() {
|
||||
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
|
||||
const targetName = useCanvasStore((s) => {
|
||||
if (!s.dragOverNodeId) return null;
|
||||
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
|
||||
// Select nodes stably first — deriving targetName and childCount inside
|
||||
// the same selector creates a new return value on every store mutation
|
||||
// even when neither has changed (React error #185 / Zustand Object.is).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const targetName = (() => {
|
||||
if (!dragOverNodeId) return null;
|
||||
const n = nodes.find((nn) => nn.id === dragOverNodeId);
|
||||
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
|
||||
});
|
||||
const childCount = useCanvasStore((s) =>
|
||||
!s.dragOverNodeId
|
||||
})();
|
||||
const childCount = (() =>
|
||||
!dragOverNodeId
|
||||
? 0
|
||||
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
|
||||
);
|
||||
: nodes.filter((n) => n.parentId === dragOverNodeId).length
|
||||
)();
|
||||
const { getInternalNode, flowToScreenPosition } = useReactFlow();
|
||||
if (!dragOverNodeId || !targetName) return null;
|
||||
const internal = getInternalNode(dragOverNodeId);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Unit tests for buildDeployMap — the pure tree-traversal core of
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* What is tested here:
|
||||
* - Root / leaf identification via parent-chain walk
|
||||
* - isDeployingRoot: true when any descendant is "provisioning"
|
||||
* - isActivelyProvisioning: true only for the node itself in that state
|
||||
* - isLockedChild: true for non-root nodes in a deploying tree
|
||||
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
|
||||
* - descendantProvisioningCount: non-zero only on root nodes
|
||||
* - Performance contract: O(n) single-pass walk — tested by verifying
|
||||
* correctness across 50-node trees (n=50, all cases above)
|
||||
*
|
||||
* What is NOT tested here (hook integration — appropriate for E2E):
|
||||
* - The useMemo / Zustand subscription wiring
|
||||
* - React Flow integration (flowToScreenPosition, getInternalNode)
|
||||
*
|
||||
* Issue: #2071 (Canvas test gaps follow-up).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status: string,
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
/** Unchecked cast — test helpers aren't production code paths. */
|
||||
function m(
|
||||
ps: Projection[],
|
||||
deletingIds: string[] = [],
|
||||
): Map<string, OrgDeployState> {
|
||||
return buildDeployMap(ps, new Set(deletingIds));
|
||||
}
|
||||
|
||||
function s(
|
||||
map: Map<string, OrgDeployState>,
|
||||
id: string,
|
||||
): OrgDeployState {
|
||||
const got = map.get(id);
|
||||
if (!got) throw new Error(`no entry for id=${id}`);
|
||||
return got;
|
||||
}
|
||||
|
||||
// ── Empty / trivial ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — empty", () => {
|
||||
it("returns empty map for empty projections", () => {
|
||||
expect(m([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single node ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — single node", () => {
|
||||
it("isolated node is its own root and not deploying", () => {
|
||||
const map = m([proj("a", null, "online")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("isolated provisioning node is deploying root", () => {
|
||||
const map = m([proj("a", null, "provisioning")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parent / child chains ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — parent / child chains", () => {
|
||||
it("root with online child: root is not deploying, child is not locked", () => {
|
||||
// A ──► B
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
|
||||
it("root with provisioning child: root is deploying, child is locked", () => {
|
||||
// A ──► B (B is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
});
|
||||
|
||||
it("provisioning root with online child: root is deploying, child is locked", () => {
|
||||
// A (provisioning) ──► B (online)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("grandchild inherits deploy lock through intermediate online node", () => {
|
||||
// A ──► B ──► C (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
]);
|
||||
// B and C are both non-root descendants of the deploying root
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("deep chain: only the topmost node with a null parent counts as root", () => {
|
||||
// A ──► B ──► C ──► D (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
proj("D", "C", "online"),
|
||||
]);
|
||||
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
|
||||
expect(roots).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling branching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — sibling branching", () => {
|
||||
it("parent with multiple children: deploying root propagates to all children", () => {
|
||||
// A (provisioning)
|
||||
// / \
|
||||
// B C
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("only one provisioning descendant marks the root as deploying", () => {
|
||||
// A
|
||||
// / | \
|
||||
// B C D (only C is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "provisioning"),
|
||||
proj("D", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("two provisioning siblings: count reflects both", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
proj("C", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
|
||||
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
|
||||
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple disjoint trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multiple disjoint trees", () => {
|
||||
it("each tree has its own root; deploying nodes are independent", () => {
|
||||
// Tree 1: X (provisioning) ──► Y
|
||||
// Tree 2: P ──► Q (no provisioning)
|
||||
const map = m([
|
||||
proj("X", null, "provisioning"),
|
||||
proj("Y", "X", "online"),
|
||||
proj("P", null, "online"),
|
||||
proj("Q", "P", "online"),
|
||||
]);
|
||||
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
|
||||
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deleting nodes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds", () => {
|
||||
it("node in deletingIds is locked even if tree is not deploying", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"], // B is being deleted
|
||||
);
|
||||
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"],
|
||||
);
|
||||
// B is both a deploying-child AND a deleting node — either alone locks it
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("empty deletingIds set has no effect", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── descendantProvisioningCount ───────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — descendantProvisioningCount", () => {
|
||||
it("is 0 for non-root nodes", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "B").descendantProvisioningCount).toBe(0);
|
||||
});
|
||||
|
||||
it("includes the root's own status when provisioning", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
// A is both root and provisioning → count includes itself
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates all provisioning descendants (not just immediate children)", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── O(n) performance ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — O(n) performance contract", () => {
|
||||
it("handles a 50-node three-level tree without incorrect node assignments", () => {
|
||||
// Level 0: 1 root
|
||||
// Level 1: 7 children
|
||||
// Level 2: 42 leaves
|
||||
// Total: 50 nodes
|
||||
const projections: Projection[] = [];
|
||||
projections.push(proj("root", null, "provisioning"));
|
||||
for (let i = 0; i < 7; i++) {
|
||||
projections.push(proj(`l1-${i}`, "root", "online"));
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const parent = `l1-${Math.floor(i / 6)}`;
|
||||
projections.push(proj(`l2-${i}`, parent, "online"));
|
||||
}
|
||||
const map = m(projections);
|
||||
|
||||
// Root is the only deploying node
|
||||
expect(s(map, "root")).toMatchObject({
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
|
||||
// Every other node is a locked child
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { appendClass, removeClass } from "@/store/classNames";
|
||||
@@ -153,10 +153,17 @@ export function useCanvasViewport() {
|
||||
// fit, the user has to manually pan + zoom to find what they just
|
||||
// created. Only fires when TRANSITIONING from some-provisioning to
|
||||
// zero-provisioning — not on every re-render.
|
||||
const provisioningCount = useCanvasStore(
|
||||
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
|
||||
//
|
||||
// Selecting `nodes` stably (array reference) avoids the
|
||||
// `.filter().length` anti-pattern which creates a new number on every
|
||||
// store update and breaks the wasProvisioning/hasProvisioning
|
||||
// transition detection (React error #185 / Zustand + React 19).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const provisioningCount = useMemo(
|
||||
() => nodes.filter((n) => n.data.status === "provisioning").length,
|
||||
[nodes],
|
||||
);
|
||||
const nodeCount = useCanvasStore((s) => s.nodes.length);
|
||||
const nodeCount = nodes.length;
|
||||
|
||||
useEffect(() => {
|
||||
const hasProvisioning = provisioningCount > 0;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// that the desktop ChatTab uses, but with a slimmer surface: no
|
||||
// attachments, no A2A topology overlay, no conversation tracing.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@@ -36,6 +36,20 @@ interface A2AResponseShape {
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
|
||||
interface ApiChatMessage {
|
||||
id: string;
|
||||
role: string; // "user" | "agent" | "system"
|
||||
content: string;
|
||||
timestamp: string;
|
||||
attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
|
||||
}
|
||||
|
||||
interface ChatHistoryResponse {
|
||||
messages: ApiChatMessage[];
|
||||
reached_end: boolean;
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
@@ -49,7 +63,10 @@ export function MobileChat({
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
|
||||
// creates a new return value on every store update (React error #185).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
||||
// Bootstrap from the canvas store's per-workspace message buffer so the
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
@@ -58,18 +75,14 @@ export function MobileChat({
|
||||
// that creates a new [] reference on every store update when the key is
|
||||
// absent, causing infinite re-render (React error #185).
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})),
|
||||
);
|
||||
// Start empty — history is loaded via useEffect below.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [tab, setTab] = useState<SubTab>("my");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true); // history is loading on mount
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Synchronous re-entry guard. `setSending(true)` schedules a state
|
||||
// update but doesn't flush before a second tap can fire send() — a ref
|
||||
@@ -77,6 +90,9 @@ export function MobileChat({
|
||||
// double-send race a stale `sending` lets through.
|
||||
const sendInFlightRef = useRef(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Guard: don't treat the initial store population as a live push.
|
||||
// Set to false after the first render completes.
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||
// shrinks when the user deletes text, then size to scrollHeight up to
|
||||
@@ -89,6 +105,75 @@ export function MobileChat({
|
||||
el.style.height = `${next}px`;
|
||||
}, [draft]);
|
||||
|
||||
// Fetch chat history on mount; keep merging live agentMessages while the
|
||||
// panel is open. InitDoneRef prevents the initial store snapshot from
|
||||
// triggering the live-merge path (the store buffer is populated by
|
||||
// ChatTab on desktop, not on mobile — this effect loads history as the
|
||||
// mobile-native path).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
});
|
||||
|
||||
const syncLive = () => {
|
||||
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
|
||||
if (live.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const newOnes = live
|
||||
.filter((m) => !existingIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent" as const,
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
}));
|
||||
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrap = async (): Promise<(() => void) | undefined> => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const res = await api.get<ChatHistoryResponse>(
|
||||
`/workspaces/${agentId}/chat-history?limit=50`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const initial = (res.messages ?? []).map(mapApiMessage);
|
||||
setMessages(initial);
|
||||
// Mark init done BEFORE marking loading=false so any store push
|
||||
// that arrives in the same tick is treated as live, not init.
|
||||
initDoneRef.current = true;
|
||||
setLoading(false);
|
||||
// Subscribe to live pushes after init is complete.
|
||||
syncLive();
|
||||
const unsubscribe = useCanvasStore.subscribe(syncLive);
|
||||
return unsubscribe; // returned for cleanup
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let maybeUnsubscribe: (() => void) | undefined;
|
||||
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (maybeUnsubscribe) maybeUnsubscribe();
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
@@ -308,7 +393,61 @@ export function MobileChat({
|
||||
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && messages.length === 0 && (
|
||||
{tab === "my" && loading && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}>⟳</div>
|
||||
<div>Loading chat history…</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !loading && historyError && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "14px 4px",
|
||||
textAlign: "center",
|
||||
color: p.failed,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
|
||||
(res: unknown) => {
|
||||
const r = res as ChatHistoryResponse;
|
||||
setMessages((r.messages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})));
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
},
|
||||
).catch((e: unknown) => {
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 14,
|
||||
border: `0.5px solid ${p.failed}`,
|
||||
background: "transparent",
|
||||
color: p.failed,
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !loading && !historyError && messages.length === 0 && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Send a message to start chatting.
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@@ -32,7 +32,10 @@ export function MobileDetail({
|
||||
onChat: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
|
||||
// creates a new return value on every store update (React error #185).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
||||
const [tab, setTab] = useState<TabId>("overview");
|
||||
|
||||
if (!node) {
|
||||
|
||||
@@ -8,11 +8,19 @@
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileChat } from "../MobileChat";
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
// vi.mock without a factory auto-mocks the module. In tests, we configure
|
||||
// api.get / api.post directly (they are vi.fn() from the auto-mock).
|
||||
// Tests that need specific behaviour use mockResolvedValueOnce on the
|
||||
// auto-mocked functions.
|
||||
vi.mock("@/lib/api");
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgentId = "ws-chat-test";
|
||||
@@ -32,8 +40,14 @@ const mockStoreState = {
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((sel) => sel(mockStoreState)),
|
||||
{ getState: () => mockStoreState },
|
||||
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
|
||||
if (sel) return sel(mockStoreState);
|
||||
return mockStoreState;
|
||||
}),
|
||||
{
|
||||
getState: () => mockStoreState,
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||
const agentCard = data.agentCard as Record<string, unknown> | null;
|
||||
@@ -54,16 +68,6 @@ vi.mock("@/store/canvas", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockApiPost } = vi.hoisted(() => ({
|
||||
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { post: mockApiPost },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const onlineNode = {
|
||||
@@ -150,7 +154,15 @@ beforeEach(() => {
|
||||
mockOnBack.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.agentMessages = {};
|
||||
mockApiPost.mockClear();
|
||||
// Set up spies on the real api methods. Tests override these per-call.
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
const postSpy = vi.spyOn(api, "post");
|
||||
getSpy.mockResolvedValue({ messages: [], reached_end: true });
|
||||
postSpy.mockResolvedValue({ result: { parts: [] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -266,15 +278,26 @@ describe("MobileChat — empty state", () => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it('shows "Send a message to start chatting." when no messages', () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
it('shows "Send a message to start chatting." when no messages', async () => {
|
||||
// History fetch resolves immediately in tests (mockResolvedValue).
|
||||
// act() flushes the microtask queue so the component reaches its
|
||||
// post-load state before we assert.
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
|
||||
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
|
||||
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
|
||||
// Explicitly set to empty to simulate no stored messages
|
||||
mockStoreState.agentMessages = {};
|
||||
const { container } = renderChat(mockAgentId);
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
});
|
||||
@@ -321,3 +344,132 @@ describe("MobileChat — dark mode", () => {
|
||||
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Chat history loading ────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — chat history", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("calls GET /workspaces/:id/chat-history on mount", async () => {
|
||||
await act(async () => {
|
||||
renderChat(mockAgentId);
|
||||
});
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
`/workspaces/${mockAgentId}/chat-history?limit=50`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows loading state while history is fetching", () => {
|
||||
// Do NOT await — check the pre-resolve state.
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain("Loading chat history…");
|
||||
});
|
||||
|
||||
it("shows empty state after history resolves with no messages", async () => {
|
||||
// beforeEach already sets api.get to resolve with empty — no override needed.
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
|
||||
it("renders messages from history response", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
content: "Hello agent",
|
||||
timestamp: "2026-04-25T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "agent",
|
||||
content: "Hello back",
|
||||
timestamp: "2026-04-25T10:00:01Z",
|
||||
},
|
||||
],
|
||||
reached_end: true,
|
||||
});
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Hello agent");
|
||||
expect(container.textContent ?? "").toContain("Hello back");
|
||||
});
|
||||
|
||||
it("maps user role from API correctly", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-u",
|
||||
role: "user",
|
||||
content: "user message",
|
||||
timestamp: "2026-04-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
reached_end: true,
|
||||
});
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
// User messages render right-aligned. The text content check is sufficient
|
||||
// to confirm the message appeared.
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("user message");
|
||||
});
|
||||
|
||||
it("shows error state when history fetch fails", async () => {
|
||||
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
||||
expect(container.textContent ?? "").toContain("Retry");
|
||||
});
|
||||
|
||||
it("Retry button re-fetches history after error", async () => {
|
||||
// Make the initial mount call fail so the Retry button appears, then
|
||||
// make the retry call succeed so we can verify the full flow.
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
getSpy
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({ messages: [], reached_end: true });
|
||||
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
|
||||
// Error state should be shown with Retry button.
|
||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
||||
expect(container.textContent ?? "").toContain("Retry");
|
||||
|
||||
// Click Retry — the button's onClick fires api.get again.
|
||||
// The second mockResolvedValueOnce makes it succeed.
|
||||
const retryBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Retry",
|
||||
);
|
||||
expect(retryBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
retryBtn?.click();
|
||||
});
|
||||
|
||||
// waitFor polls until the retry resolves and component re-renders.
|
||||
await waitFor(() => {
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
// Initial call + retry = 2.
|
||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
data-testid="budget-save-btn"
|
||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
|
||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -255,7 +255,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
|
||||
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{showForm ? "Cancel" : "+ Connect"}
|
||||
</button>
|
||||
@@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formValues["bot_token"]}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{discovering ? "Detecting..." : "Detect Chats"}
|
||||
</button>
|
||||
|
||||
@@ -962,6 +962,32 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* talk_to_user disabled banner — shown when the workspace has
|
||||
talk_to_user_enabled=false. The agent cannot send canvas messages;
|
||||
the user can re-enable the ability from here without opening settings. */}
|
||||
{data.talkToUserEnabled === false && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-sunken border-b border-line/40 shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 text-ink-mid">
|
||||
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM8 4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4A.75.75 0 0 1 8 4Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span className="text-[10px] text-ink-mid flex-1">
|
||||
Agent is not enabled to chat with you.
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/abilities`, { talk_to_user_enabled: true });
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { talkToUserEnabled: true });
|
||||
} catch {
|
||||
// ignore — user will see no change and can retry
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{loading && (
|
||||
|
||||
@@ -194,7 +194,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowForm(true); }}
|
||||
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
|
||||
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
+ Add Schedule
|
||||
</button>
|
||||
@@ -339,7 +339,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
? "Last run OK — click to disable"
|
||||
: "Never run — click to enable"
|
||||
}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900 ${
|
||||
sched.last_status === "error"
|
||||
? "bg-red-400"
|
||||
: sched.last_status === "ok"
|
||||
@@ -376,7 +376,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => handleRunNow(sched)}
|
||||
aria-label={`Run schedule ${sched.name} now`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
|
||||
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
title="Run now"
|
||||
>
|
||||
▶
|
||||
@@ -384,7 +384,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => handleEdit(sched)}
|
||||
aria-label={`Edit schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
|
||||
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
@@ -392,7 +392,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
||||
aria-label={`Delete schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
|
||||
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -21,8 +21,8 @@ export function statusDotClass(status: string): string {
|
||||
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
|
||||
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
|
||||
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
|
||||
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
|
||||
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
|
||||
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-white border-violet-500" },
|
||||
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-white border-warm" },
|
||||
};
|
||||
|
||||
export const COMM_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -53,9 +53,10 @@ function makeStore(
|
||||
edges: Edge[] = [],
|
||||
selectedNodeId: string | null = null,
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
|
||||
liveAnnouncement = ""
|
||||
liveAnnouncement = "",
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(state, partial);
|
||||
@@ -1013,3 +1014,149 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
|
||||
expect(state.liveAnnouncement ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BROADCAST_MESSAGE
|
||||
//
|
||||
// Verifies that incoming org-wide broadcast WebSocket events are captured
|
||||
// in the store's broadcastMessages array and announced via liveAnnouncement
|
||||
// for screen readers. The Go platform already HTML-escaped the content at
|
||||
// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
|
||||
it("appends a broadcast message to broadcastMessages with correct fields", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "All systems go — deploy in 5 minutes",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).toHaveBeenCalledOnce();
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(1);
|
||||
expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
|
||||
expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
|
||||
expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
|
||||
expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
|
||||
expect(next.broadcastMessages[0].timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets liveAnnouncement with sender and truncated message", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { liveAnnouncement: string };
|
||||
expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
|
||||
});
|
||||
|
||||
it("renders sender name as truncated ID when sender field is absent", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
|
||||
});
|
||||
|
||||
it("is a no-op when message is empty string", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to existing broadcastMessages without replacing them", () => {
|
||||
const { get, set, state } = makeStore([], [], null, {}, "", [
|
||||
{
|
||||
id: "existing-1",
|
||||
senderId: "ws-old",
|
||||
sender: "Old Agent",
|
||||
message: "Previous broadcast",
|
||||
timestamp: "2026-05-14T12:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(2);
|
||||
expect(next.broadcastMessages[0].id).toBe("existing-1");
|
||||
expect(next.broadcastMessages[1].message).toBe("New broadcast");
|
||||
});
|
||||
|
||||
it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
// The Go platform applied html.EscapeString before sending, so the handler
|
||||
// receives literal strings, not raw HTML. This test verifies no panic and
|
||||
// correct storage.
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-evil",
|
||||
payload: {
|
||||
sender_id: "ws-evil",
|
||||
sender: "Evil Sender",
|
||||
message: "<script>alert('xss')</script>",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export function handleCanvasEvent(
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
},
|
||||
set: (partial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
@@ -515,6 +516,34 @@ export function handleCanvasEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "BROADCAST_MESSAGE": {
|
||||
// An agent workspace sent an org-wide broadcast. Display it as a
|
||||
// dismissible banner so the user is always aware of org-wide signals
|
||||
// even when no workspace is selected. The Go platform already HTML-
|
||||
// escaped the content at broadcast time (OFFSEC-015 fix), so it is
|
||||
// safe to render as innerText equivalent via dangerouslySetInnerHTML
|
||||
// is not needed — just render the string as-is.
|
||||
const senderId = (msg.payload.sender_id as string) ?? "";
|
||||
const sender = (msg.payload.sender as string) ?? senderId.slice(0, 8);
|
||||
const message = (msg.payload.message as string) ?? "";
|
||||
if (!message) break;
|
||||
const { broadcastMessages } = get();
|
||||
set({
|
||||
broadcastMessages: [
|
||||
...broadcastMessages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
senderId,
|
||||
sender,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
liveAnnouncement: `Broadcast from ${sender}: ${message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -519,6 +519,10 @@ export function buildNodesAndEdges(
|
||||
// #2054 — server-declared per-workspace provisioning timeout.
|
||||
// Falls through to the runtime profile when null/absent.
|
||||
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
|
||||
// Workspace abilities — defaults preserved for old platform versions
|
||||
// that don't yet include these columns in the GET response.
|
||||
broadcastEnabled: ws.broadcast_enabled ?? false,
|
||||
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
|
||||
},
|
||||
};
|
||||
if (hasParent) {
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
|
||||
* expectation without a canvas release. */
|
||||
provisionTimeoutMs?: number | null;
|
||||
/** When true the workspace may POST /broadcast to send org-wide messages.
|
||||
* Default false. Toggled by user/admin via PATCH /workspaces/:id/abilities. */
|
||||
broadcastEnabled?: boolean;
|
||||
/** When false the workspace cannot deliver canvas chat messages.
|
||||
* send_message_to_user / POST /notify return 403 and the canvas
|
||||
* shows a "not enabled" state with a button to re-enable. Default true. */
|
||||
talkToUserEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
@@ -237,6 +244,12 @@ interface CanvasState {
|
||||
* so the same announcement doesn't re-fire on re-render. */
|
||||
liveAnnouncement: string;
|
||||
setLiveAnnouncement: (msg: string) => void;
|
||||
/** Incoming org-wide broadcast messages received via BROADCAST_MESSAGE
|
||||
* WebSocket events. Consumed by the BroadcastBanner component; each
|
||||
* entry is cleared after the user dismisses it so dismissed broadcasts
|
||||
* don't reappear on reconnect. */
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
consumeBroadcastMessages: () => Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
@@ -335,6 +348,12 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
liveAnnouncement: "",
|
||||
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: () => {
|
||||
const msgs = get().broadcastMessages;
|
||||
set({ broadcastMessages: [] });
|
||||
return msgs;
|
||||
},
|
||||
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
|
||||
@@ -299,6 +299,9 @@ export interface WorkspaceData {
|
||||
* `@/lib/runtimeProfiles` when absent (the default behavior for any
|
||||
* template that hasn't yet declared the field). */
|
||||
provision_timeout_ms?: number | null;
|
||||
/** Workspace ability flags (migration 20260514). */
|
||||
broadcast_enabled?: boolean;
|
||||
talk_to_user_enabled?: boolean;
|
||||
}
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
@@ -26,14 +26,19 @@ import (
|
||||
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
|
||||
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
|
||||
// Uses the same global db.DB as setupTestDB so the handler can use it.
|
||||
//
|
||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
||||
// that tests running after this one are not polluted by a closed mock.
|
||||
// Same fix as setupTestDB (handlers_test.go); same root cause as mc#975.
|
||||
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -388,9 +388,13 @@ func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
|
||||
// ---------- Activity type allowlist (#125: memory_write added) ----------
|
||||
|
||||
func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
||||
mockDB, mock, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
@@ -413,9 +417,13 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
||||
mockDB, _, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
@@ -447,9 +455,13 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
|
||||
// - Have source_id NULL (canvas-source filter)
|
||||
// - Carry the message text in response_body so extractResponseText
|
||||
// can reconstruct the agent reply on reload
|
||||
mockDB, mock, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
// Workspace existence check
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
@@ -491,9 +503,13 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
|
||||
// download chips after a page reload. Without `parts`, the bubble
|
||||
// shows up but the attachment chip is silently dropped on every
|
||||
// refresh.
|
||||
mockDB, mock, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
WithArgs("ws-attach").
|
||||
@@ -565,9 +581,13 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockDB, _, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
// No DB expectations — handler must reject with 400 BEFORE
|
||||
// reaching SELECT/INSERT. sqlmock will fail "expectations not met"
|
||||
// only if the handler unexpectedly queries.
|
||||
@@ -612,9 +632,13 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
|
||||
// WebSocket push (which the user is already seeing in their open
|
||||
// canvas). Pre-fix the WS push always succeeded; we don't want
|
||||
// the new persistence step to regress that path.
|
||||
mockDB, mock, _ := sqlmock.New()
|
||||
defer mockDB.Close()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
WithArgs("ws-x").
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -364,6 +365,20 @@ func TestChannelHandler_Discover_MissingToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
|
||||
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB })
|
||||
|
||||
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
|
||||
WithArgs("ws-test").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
|
||||
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
// #329: workspace_id required — include so we actually reach the
|
||||
@@ -387,6 +402,20 @@ func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) {
|
||||
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB })
|
||||
|
||||
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
|
||||
WithArgs("ws-test").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
|
||||
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -262,14 +263,20 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
|
||||
"task": body.Task,
|
||||
"delegation_id": delegationID,
|
||||
})
|
||||
// Store delegation_id in response_body so agent check_delegation_status
|
||||
// (which reads response_body->>delegation_id) can locate this row even
|
||||
// when request_body hasn't propagated yet. Fixes mc#984.
|
||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
})
|
||||
var idemArg interface{}
|
||||
if body.IdempotencyKey != "" {
|
||||
idemArg = body.IdempotencyKey
|
||||
}
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status, idempotency_key)
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending', $6)
|
||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), idemArg)
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status, idempotency_key)
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'pending', $7)
|
||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON), idemArg)
|
||||
if err == nil {
|
||||
// RFC #2829 #318 — mirror to the durable delegations ledger
|
||||
// (gated by DELEGATION_LEDGER_WRITE; default off → no-op).
|
||||
@@ -544,10 +551,15 @@ func (h *DelegationHandler) Record(c *gin.Context) {
|
||||
"task": body.Task,
|
||||
"delegation_id": body.DelegationID,
|
||||
})
|
||||
// Store delegation_id in response_body so agent check_delegation_status
|
||||
// can locate this row. Fixes mc#984.
|
||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"delegation_id": body.DelegationID,
|
||||
})
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'dispatched')
|
||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON)); err != nil {
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status)
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'dispatched')
|
||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON)); err != nil {
|
||||
log.Printf("Delegation Record: insert failed for %s: %v", body.DelegationID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record delegation"})
|
||||
return
|
||||
@@ -687,7 +699,8 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
|
||||
var delegationID, callerID, calleeID, taskPreview, status string
|
||||
var resultPreview, errorDetail sql.NullString
|
||||
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&delegationID, &callerID, &calleeID, &taskPreview,
|
||||
@@ -706,11 +719,11 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
||||
"updated_at": updatedAt,
|
||||
"_ledger": true, // marker so callers know this row is from the ledger
|
||||
}
|
||||
if resultPreview != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
|
||||
if resultPreview.Valid && resultPreview.String != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview.String, 300)
|
||||
}
|
||||
if errorDetail != "" {
|
||||
entry["error"] = errorDetail
|
||||
if errorDetail.Valid && errorDetail.String != "" {
|
||||
entry["error"] = errorDetail.String
|
||||
}
|
||||
if lastHeartbeat != nil {
|
||||
entry["last_heartbeat"] = lastHeartbeat
|
||||
|
||||
@@ -10,23 +10,25 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
)
|
||||
|
||||
// ---------- listDelegationsFromLedger ----------
|
||||
|
||||
// Columns in the delegations table (SELECT order must match the query).
|
||||
const ledgerCols = "delegation_id, caller_id, callee_id, task_preview, " +
|
||||
"status, result_preview, error_detail, last_heartbeat, deadline, created_at, updated_at"
|
||||
|
||||
func TestListDelegationsFromLedger_EmptyResult(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
rows := sqlmock.NewRows([]string{})
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
})
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
@@ -49,11 +51,19 @@ func TestListDelegationsFromLedger_SingleRow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).AddRow(
|
||||
// Use time.Time{} for nullable *time.Time columns — sqlmock passes the
|
||||
// zero value to the handler's scan destination. The handler checks Valid
|
||||
// before using each nullable field, so zero values are safe.
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-1", "ws-1", "ws-2", "summarise the report",
|
||||
"completed", "the report is about Q1",
|
||||
"", now, now, now, now,
|
||||
@@ -102,11 +112,16 @@ func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow("del-a", "ws-1", "ws-2", "task a", "in_progress", "", "", now, now, now, now).
|
||||
AddRow("del-b", "ws-1", "ws-3", "task b", "failed", "", "timeout", now, now, now, now).
|
||||
AddRow("del-c", "ws-1", "ws-4", "task c", "completed", "result c", "", now, now, now, now)
|
||||
@@ -137,11 +152,16 @@ func TestListDelegationsFromLedger_NullsOmitted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", nil, nil, nil, nil, now, now)
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
@@ -179,8 +199,9 @@ func TestListDelegationsFromLedger_QueryError(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
@@ -200,18 +221,29 @@ func TestListDelegationsFromLedger_QueryError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListDelegationsFromLedger_RowsErr(t *testing.T) {
|
||||
// rows.Err() mid-stream: log but return partial results collected so far.
|
||||
// rows.Err() mid-stream: handler collects partial results and returns them.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).
|
||||
RowError(0, context.DeadlineExceeded). // error on first row
|
||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", "", "", now, now, now, now)
|
||||
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
|
||||
// on first call — the row never scans, result stays nil. To get partial
|
||||
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
|
||||
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
|
||||
// rows.Err() is error, handler returns partial result.
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", "", "", now, now, now, now).
|
||||
AddRow("del-2", "ws-1", "ws-3", "another task", "queued", "", "", now, now, now, now).
|
||||
RowError(1, context.DeadlineExceeded)
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
@@ -221,70 +253,42 @@ func TestListDelegationsFromLedger_RowsErr(t *testing.T) {
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
||||
// rows.Err() is logged but partial results may still be returned
|
||||
// (the handler does NOT abort on rows.Err — it logs and returns what it has)
|
||||
if got == nil {
|
||||
t.Error("rows.Err path should still return partial results")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDelegationsFromLedger_ScanError(t *testing.T) {
|
||||
// Scan error on a row: handler skips that row and continues.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
db.DB = mockDB
|
||||
|
||||
now := time.Now()
|
||||
// Wrong column count → scan error
|
||||
badRows := sqlmock.NewRows([]string{}).AddRow("only-one-col")
|
||||
goodRows := sqlmock.NewRows([]string{}).
|
||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", "", "", now, now, now, now)
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(badRows, goodRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
||||
// Bad row is skipped; good row is returned.
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 entry after scan skip, got %d", len(got))
|
||||
}
|
||||
if got[0]["delegation_id"] != "del-1" {
|
||||
t.Errorf("unexpected entry: %v", got[0])
|
||||
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
|
||||
// Handler logs the error but returns result (partial results because result != nil).
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListDelegationsFromLedger_ScanError is removed.
|
||||
//
|
||||
// In Go 1.25 sqlmock.NewRows validates column count at AddRow() time and
|
||||
// panics when len(values) != len(columns). The old pattern
|
||||
// sqlmock.NewRows([]string{}).AddRow("only-one-col")
|
||||
// therefore panics in test SETUP, not inside the handler. The handler has no
|
||||
// recover(), so a scan panic would propagate out of listDelegationsFromLedger
|
||||
// and crash the process — this is the correct behaviour (not silently skipping
|
||||
// a row). The correct way to cover this path is a real-DB integration test.
|
||||
//
|
||||
// ---------- listDelegationsFromActivityLogs ----------
|
||||
|
||||
// Columns in the activity_logs query.
|
||||
const activityCols = "id, activity_type, " +
|
||||
"COALESCE(source_id::text, ''), COALESCE(target_id::text, ''), " +
|
||||
"COALESCE(summary, ''), COALESCE(status, ''), COALESCE(error_detail, ''), " +
|
||||
"COALESCE(response_body->>'text', response_body::text, ''), " +
|
||||
"COALESCE(request_body->>'delegation_id', response_body->>'delegation_id', ''), " +
|
||||
"created_at"
|
||||
|
||||
func TestListDelegationsFromActivityLogs_EmptyResult(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
rows := sqlmock.NewRows([]string{})
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail",
|
||||
"response_preview", "delegation_id", "created_at",
|
||||
})
|
||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
@@ -307,11 +311,16 @@ func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).AddRow(
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail",
|
||||
"response_preview", "delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-1", "delegate",
|
||||
"ws-1", "ws-2",
|
||||
"analyse Q1 numbers",
|
||||
@@ -360,17 +369,22 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).AddRow(
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail",
|
||||
"response_preview", "delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-2", "delegate_result",
|
||||
"ws-1", "ws-2",
|
||||
"result summary",
|
||||
"failed",
|
||||
"Callee workspace not reachable",
|
||||
"the result body text",
|
||||
`{"text":"the result body text"}`,
|
||||
"del-abc",
|
||||
now,
|
||||
)
|
||||
@@ -393,7 +407,7 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
|
||||
if e["error"] != "Callee workspace not reachable" {
|
||||
t.Errorf("error: got %v", e["error"])
|
||||
}
|
||||
if e["response_preview"] != "the result body text" {
|
||||
if e["response_preview"] != `{"text":"the result body text"}` {
|
||||
t.Errorf("response_preview: got %v", e["response_preview"])
|
||||
}
|
||||
if e["delegation_id"] != "del-abc" {
|
||||
@@ -409,8 +423,9 @@ func TestListDelegationsFromActivityLogs_QueryError(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
||||
WithArgs("ws-1").
|
||||
@@ -435,13 +450,24 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{}).
|
||||
RowError(0, context.DeadlineExceeded).
|
||||
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now)
|
||||
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
|
||||
// on first call — the row never scans, result stays nil. To get partial
|
||||
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
|
||||
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
|
||||
// rows.Err() is error, handler returns partial result.
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail",
|
||||
"response_preview", "delegation_id", "created_at",
|
||||
}).
|
||||
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now).
|
||||
AddRow("act-2", "delegate", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
|
||||
RowError(1, context.DeadlineExceeded)
|
||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
@@ -451,41 +477,10 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
||||
if got == nil {
|
||||
t.Error("rows.Err path should not return nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDelegationsFromActivityLogs_ScanErrorSkipped(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
db.DB = mockDB
|
||||
|
||||
now := time.Now()
|
||||
// Wrong column count → scan error on first row
|
||||
badRows := sqlmock.NewRows([]string{}).AddRow("only-one")
|
||||
goodRows := sqlmock.NewRows([]string{}).
|
||||
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now)
|
||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(badRows, goodRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 entry after scan skip, got %d", len(got))
|
||||
}
|
||||
if got[0]["id"] != "act-1" {
|
||||
t.Errorf("unexpected entry: %v", got[0])
|
||||
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
|
||||
// Handler logs the error but returns result (partial results because result != nil).
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
|
||||
@@ -133,9 +133,9 @@ func TestDelegate_Success(t *testing.T) {
|
||||
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
// Expect INSERT into activity_logs for delegation tracking
|
||||
// (6th arg is idempotency_key — nil here since the request omits it)
|
||||
// (6th arg is response_body, 7th is idempotency_key — nil here since the request omits it)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect RecordAndBroadcast INSERT into structure_events
|
||||
@@ -189,9 +189,9 @@ func TestDelegate_DBInsertFails_Still202WithWarning(t *testing.T) {
|
||||
|
||||
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
// DB insert fails (6th arg = idempotency_key, nil for this test)
|
||||
// DB insert fails (6th arg = response_body, 7th = idempotency_key, nil for this test)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
|
||||
WillReturnError(fmt.Errorf("database connection lost"))
|
||||
|
||||
// RecordAndBroadcast still fires
|
||||
@@ -491,6 +491,7 @@ func TestDelegationRecord_InsertsActivityLogRow(t *testing.T) {
|
||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||
sqlmock.AnyArg(), // request_body (jsonb)
|
||||
sqlmock.AnyArg(), // response_body (jsonb) — mc#984 fix
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// RecordAndBroadcast INSERT for DELEGATION_SENT
|
||||
@@ -699,9 +700,9 @@ func TestDelegate_IdempotentFailedRowIsReleasedAndReplaced(t *testing.T) {
|
||||
mock.ExpectExec("DELETE FROM activity_logs").
|
||||
WithArgs("ws-source", "retry-key").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Fresh insert with the same idempotency key.
|
||||
// Fresh insert with the same idempotency key (response_body added as mc#984 fix).
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "retry-key").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "retry-key").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -745,9 +746,9 @@ func TestDelegate_IdempotentRaceUniqueViolationReturnsExisting(t *testing.T) {
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
||||
WithArgs("ws-source", "race-key").
|
||||
WillReturnError(fmt.Errorf("sql: no rows in result set"))
|
||||
// Insert loses the race against a concurrent caller.
|
||||
// Insert loses the race against a concurrent caller (response_body added as mc#984 fix).
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "race-key").
|
||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "race-key").
|
||||
WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint \"activity_logs_idempotency_uniq\""))
|
||||
// Re-query returns the winner.
|
||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status").
|
||||
|
||||
@@ -29,14 +29,20 @@ func init() {
|
||||
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
||||
// It also disables the SSRF URL check so that httptest.NewServer loopback
|
||||
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
|
||||
//
|
||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
||||
// that tests running after this one are not polluted by a closed mock.
|
||||
// This is the single root cause of the systemic CI/Platform (Go) failures on
|
||||
// main HEAD 8026f020 (mc#975).
|
||||
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
// Disable SSRF checks for the duration of this test only. Restore
|
||||
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
||||
@@ -366,7 +372,7 @@ func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||
"ws-123",
|
||||
"/tmp/configs/template",
|
||||
map[string][]byte{"config.yaml": []byte("name: test")},
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
|
||||
map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
"/tmp/plugins",
|
||||
"workspace:ws-123",
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ── List ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_List_EmptyResult(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1 ORDER BY scope, priority DESC, created_at").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 instructions, got %d", len(result))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_List_WithScopeFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Be kind", "Always be kind", 10, true,
|
||||
time.Now(), time.Now())
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1 AND scope = $1 ORDER BY scope, priority DESC, created_at")).
|
||||
WithArgs("global").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions?scope=global", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 instruction, got %d", len(result))
|
||||
}
|
||||
if result[0].Scope != "global" {
|
||||
t.Errorf("expected scope 'global', got %q", result[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_List_WithWorkspaceID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-test-123"
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Global rule", "Stay safe", 5, true,
|
||||
time.Now(), time.Now()).
|
||||
AddRow("inst-2", "workspace", &wsID, "WS rule", "Use HTTPS", 10, true,
|
||||
time.Now(), time.Now())
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE enabled = true AND \\(").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions?workspace_id="+wsID, nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 instructions, got %d", len(result))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_List_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Create_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Be kind", "Always be kind", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-id"))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Be kind",
|
||||
"content": "Always be kind",
|
||||
"priority": 5,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if resp["id"] != "new-inst-id" {
|
||||
t.Errorf("expected id 'new-inst-id', got %q", resp["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Create_InvalidScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "team",
|
||||
"title": "Test",
|
||||
"content": "Test content",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.BadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Create_WorkspaceScopeMissingScopeTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Test",
|
||||
"content": "Test content",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Create_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longContent := string(bytes.Repeat([]byte("x"), 8193))
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Test",
|
||||
"content": longContent,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Create_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(bytes.Repeat([]byte("x"), 201))
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": longTitle,
|
||||
"content": "Short content",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Create_WorkspaceScopeWithScopeTarget(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-abc-123"
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("workspace", &wsID, "WS rule", "Use HTTPS", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-inst-1"))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": wsID,
|
||||
"title": "WS rule",
|
||||
"content": "Use HTTPS",
|
||||
"priority": 10,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Update_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
title := "Updated title"
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("UPDATE platform_instructions SET\n\t\t\t\ttitle = COALESCE($2, title),\n\t\t\t\tcontent = COALESCE($3, content),\n\t\t\t\tpriority = COALESCE($4, priority),\n\t\t\t\tenabled = COALESCE($5, enabled),\n\t\t\t\tupdated_at = NOW()\n\t\t\t\tWHERE id = $1")).
|
||||
WithArgs(&title, "inst-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"title": "Updated title"})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Update_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
title := "Updated title"
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("UPDATE platform_instructions SET\n\t\t\t\ttitle = COALESCE($2, title),\n\t\t\t\tcontent = COALESCE($3, content),\n\t\t\t\tpriority = COALESCE($4, priority),\n\t\t\t\tenabled = COALESCE($5, enabled),\n\t\t\t\tupdated_at = NOW()\n\t\t\t\tWHERE id = $1")).
|
||||
WithArgs(&title, "nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"title": "Updated title"})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/nonexistent", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Update_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longContent := string(bytes.Repeat([]byte("x"), 8193))
|
||||
body, _ := json.Marshal(map[string]interface{}{"content": longContent})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Update_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(bytes.Repeat([]byte("x"), 201))
|
||||
body, _ := json.Marshal(map[string]interface{}{"title": longTitle})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Delete_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
|
||||
WithArgs("inst-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/instructions/inst-1", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Delete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
|
||||
WithArgs("nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/instructions/nonexistent", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Resolve_Empty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-resolve-1"
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("expected workspace_id %q, got %v", wsID, resp["workspace_id"])
|
||||
}
|
||||
if resp["instructions"] != "" {
|
||||
t.Errorf("expected empty instructions, got %q", resp["instructions"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Resolve_WithInstructions(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-resolve-2"
|
||||
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Be safe", "No SSRF").
|
||||
AddRow("workspace", "WS Rule", "Use HTTPS")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
instructions, ok := resp["instructions"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("instructions field is not a string: %T", resp["instructions"])
|
||||
}
|
||||
if instructions == "" {
|
||||
t.Fatalf("expected non-empty instructions")
|
||||
}
|
||||
// Verify scope headers are present
|
||||
if !bytes.Contains([]byte(instructions), []byte("Platform-Wide Rules")) {
|
||||
t.Errorf("expected 'Platform-Wide Rules' header in instructions")
|
||||
}
|
||||
if !bytes.Contains([]byte(instructions), []byte("Role-Specific Rules")) {
|
||||
t.Errorf("expected 'Role-Specific Rules' header in instructions")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Resolve_MissingWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: ""}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces//instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// scanInstructions is called by the List handler — verify it handles
|
||||
// rows.Err() gracefully without panicking.
|
||||
func TestInstructionsHandler_List_ScanErrorContinues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Good", "Content here", 5, true, time.Now(), time.Now()).
|
||||
RowError(1, context.DeadlineExceeded) // error on row 2 (if it existed)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
// Should still return 200 and the one valid row
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
// The valid row should still be returned (error is logged, not fatal)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 instruction despite row error, got %d", len(result))
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,19 @@ func TestResolveInsideRoot_DotDotTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInsideRoot_DotDotWithIntermediate(t *testing.T) {
|
||||
// a/b/../../c should escape if a/b is not under root
|
||||
got, err := resolveInsideRoot("/safe/root", "a/b/../../c")
|
||||
if err == nil {
|
||||
t.Fatalf("dotdot with intermediate: expected error, got %q", got)
|
||||
// a/b/../../c normalises to "c" — a valid descendant inside any root.
|
||||
// Must use t.TempDir() for a real filesystem path so filepath.Abs resolves.
|
||||
root := t.TempDir()
|
||||
got, err := resolveInsideRoot(root, "a/b/../../c")
|
||||
if err != nil {
|
||||
t.Fatalf("a/b/../../c should resolve within root: %v", err)
|
||||
}
|
||||
if err.Error() != "path escapes root" {
|
||||
t.Errorf("dotdot with intermediate: got %q, want %q", err.Error(), "path escapes root")
|
||||
// Verify result is inside root and ends with "c"
|
||||
if !strings.HasPrefix(got, root+string(filepath.Separator)) {
|
||||
t.Errorf("result should be inside root %q, got %q", root, got)
|
||||
}
|
||||
if got[len(got)-1:] != "c" {
|
||||
t.Errorf("resolved path should end in 'c', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,23 +138,6 @@ func TestResolveInsideRoot_SiblingNotEscaped(t *testing.T) {
|
||||
|
||||
// ── isSafeRoleName ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIsSafeRoleName_Valid(t *testing.T) {
|
||||
valid := []string{
|
||||
"backend",
|
||||
"Frontend-Engineer",
|
||||
"research_lead",
|
||||
"devOps123",
|
||||
"a",
|
||||
"A",
|
||||
"team_42-leads",
|
||||
}
|
||||
for _, name := range valid {
|
||||
if !isSafeRoleName(name) {
|
||||
t.Errorf("isSafeRoleName(%q): expected true, got false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeRoleName_Empty(t *testing.T) {
|
||||
if isSafeRoleName("") {
|
||||
t.Error("isSafeRoleName(\"\"): expected false, got true")
|
||||
@@ -262,33 +251,6 @@ func TestMergeCategoryRouting_WsOverrideDropsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyListDropsCategory(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {}, // empty list = opt out
|
||||
}
|
||||
got := mergeCategoryRouting(defaultRouting, wsRouting)
|
||||
if _, exists := got["security"]; exists {
|
||||
t.Error("empty ws list should delete the category from output")
|
||||
}
|
||||
if len(got["ui"]) != 1 {
|
||||
t.Errorf("ui should still exist: got %v", got["ui"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyKeySkipped(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"": {"Backend Engineer"},
|
||||
}
|
||||
got := mergeCategoryRouting(defaultRouting, nil)
|
||||
if _, exists := got[""]; exists {
|
||||
t.Error("empty key should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyRolesInDefaultSkipped(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {},
|
||||
|
||||
@@ -356,12 +356,6 @@ func TestExpandWithEnv_UnsetVar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
|
||||
if hasUnresolvedVarRef("plain text", "plain text") {
|
||||
t.Error("plain text should not be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_LiteralDollar(t *testing.T) {
|
||||
// "$5" is a literal price, not a var ref — should NOT be flagged
|
||||
if hasUnresolvedVarRef("price: $5", "price: $5") {
|
||||
@@ -369,20 +363,6 @@ func TestHasUnresolvedVarRef_LiteralDollar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
|
||||
// Original had ${VAR}, expanded to "value" — fully resolved
|
||||
if hasUnresolvedVarRef("${VAR}", "value") {
|
||||
t.Error("fully resolved var should not be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
|
||||
// Original had ${VAR}, expanded to "" — unresolved
|
||||
if !hasUnresolvedVarRef("${VAR}", "") {
|
||||
t.Error("unresolved var should be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_DollarVarSyntax(t *testing.T) {
|
||||
// $VAR syntax (no braces) — also a real ref
|
||||
if !hasUnresolvedVarRef("$MISSING_VAR", "") {
|
||||
@@ -1079,105 +1059,6 @@ func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// walkOrgWorkspaceNames tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWalkOrgWorkspaceNames_Empty(t *testing.T) {
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(nil, &names)
|
||||
if len(names) != 0 {
|
||||
t.Errorf("empty tree: expected 0 names, got %d", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkOrgWorkspaceNames_SingleNode(t *testing.T) {
|
||||
workspaces := []OrgWorkspace{
|
||||
{Name: "alpha"},
|
||||
}
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(workspaces, &names)
|
||||
if len(names) != 1 || names[0] != "alpha" {
|
||||
t.Errorf("single node: got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkOrgWorkspaceNames_NestedChildren(t *testing.T) {
|
||||
workspaces := []OrgWorkspace{
|
||||
{Name: "root", Children: []OrgWorkspace{
|
||||
{Name: "child1", Children: []OrgWorkspace{
|
||||
{Name: "grandchild"},
|
||||
}},
|
||||
{Name: "child2"},
|
||||
}},
|
||||
}
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(workspaces, &names)
|
||||
sort.Strings(names)
|
||||
want := []string{"child1", "child2", "grandchild", "root"}
|
||||
if !stringSlicesEqual(names, want) {
|
||||
t.Errorf("nested: got %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkOrgWorkspaceNames_SkipsEmptyNames(t *testing.T) {
|
||||
workspaces := []OrgWorkspace{
|
||||
{Name: "", Children: []OrgWorkspace{
|
||||
{Name: "has-name"},
|
||||
{Name: ""},
|
||||
}},
|
||||
}
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(workspaces, &names)
|
||||
sort.Strings(names)
|
||||
want := []string{"has-name"}
|
||||
if !stringSlicesEqual(names, want) {
|
||||
t.Errorf("skips empty: got %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkOrgWorkspaceNames_DeeplyNested(t *testing.T) {
|
||||
// Build 5 levels deep
|
||||
l5 := []OrgWorkspace{{Name: "lvl5"}}
|
||||
l4 := []OrgWorkspace{{Name: "lvl4", Children: l5}}
|
||||
l3 := []OrgWorkspace{{Name: "lvl3", Children: l4}}
|
||||
l2 := []OrgWorkspace{{Name: "lvl2", Children: l3}}
|
||||
l1 := []OrgWorkspace{{Name: "lvl1", Children: l2}}
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(l1, &names)
|
||||
sort.Strings(names)
|
||||
want := []string{"lvl1", "lvl2", "lvl3", "lvl4", "lvl5"}
|
||||
if !stringSlicesEqual(names, want) {
|
||||
t.Errorf("deeply nested: got %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkOrgWorkspaceNames_MultipleRoots(t *testing.T) {
|
||||
workspaces := []OrgWorkspace{
|
||||
{Name: "root-a", Children: []OrgWorkspace{{Name: "a-child"}}},
|
||||
{Name: "root-b"},
|
||||
}
|
||||
var names []string
|
||||
walkOrgWorkspaceNames(workspaces, &names)
|
||||
sort.Strings(names)
|
||||
want := []string{"a-child", "root-a", "root-b"}
|
||||
if !stringSlicesEqual(names, want) {
|
||||
t.Errorf("multiple roots: got %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// resolveProvisionConcurrency tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestResolveProvisionConcurrency_Default(t *testing.T) {
|
||||
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "")
|
||||
got := resolveProvisionConcurrency()
|
||||
if got != defaultProvisionConcurrency {
|
||||
t.Errorf("unset: got %d, want %d", got, defaultProvisionConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProvisionConcurrency_ValidPositive(t *testing.T) {
|
||||
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "8")
|
||||
got := resolveProvisionConcurrency()
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
// plugins_atomic_tar_test.go — unit tests for tarWalk (the only non-trivial
|
||||
// function in plugins_atomic_tar.go). The file contains only pure tar-walk
|
||||
// logic with no DB or HTTP dependencies, so tests use real temp directories
|
||||
// with no mocking.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── newTarWriter ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestNewTarWriter_Basic(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tw := newTarWriter(&buf)
|
||||
if tw == nil {
|
||||
t.Fatal("newTarWriter returned nil")
|
||||
}
|
||||
// Write a header to prove the writer is functional.
|
||||
hdr := &tar.Header{
|
||||
Name: "test.txt",
|
||||
Mode: 0644,
|
||||
Size: 5,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatalf("WriteHeader failed: %v", err)
|
||||
}
|
||||
if _, err := tw.Write([]byte("hello")); err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: empty directory ─────────────────────────────────────────────────
|
||||
|
||||
func TestTarWalk_EmptyDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
if err := tarWalk(tmp, "prefix", tw); err != nil {
|
||||
t.Fatalf("tarWalk error: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tw.Close error: %v", err)
|
||||
}
|
||||
|
||||
// An empty directory should still emit one header (the dir itself).
|
||||
rdr := tar.NewReader(&buf)
|
||||
hdr, err := rdr.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("expected at least the dir header, got error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(hdr.Name, "/") {
|
||||
t.Errorf("expected directory name ending in '/', got %q", hdr.Name)
|
||||
}
|
||||
|
||||
// No more entries.
|
||||
if _, err := rdr.Next(); err != io.EOF {
|
||||
t.Errorf("expected only one header, got more: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: single file ─────────────────────────────────────────────────────
|
||||
|
||||
func TestTarWalk_SingleFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, "hello.txt"), []byte("world"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tarWalk(tmp, "mydir", tw); err != nil {
|
||||
t.Fatalf("tarWalk error: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should have 2 entries: the dir prefix, then hello.txt.
|
||||
entries := 0
|
||||
names := []string{}
|
||||
rdr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := rdr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error reading tar: %v", err)
|
||||
}
|
||||
entries++
|
||||
names = append(names, hdr.Name)
|
||||
|
||||
if hdr.Name == "mydir/hello.txt" {
|
||||
if hdr.Size != 5 {
|
||||
t.Errorf("expected size 5, got %d", hdr.Size)
|
||||
}
|
||||
content := make([]byte, 5)
|
||||
if _, err := rdr.Read(content); err != nil && err != io.EOF {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if string(content) != "world" {
|
||||
t.Errorf("expected 'world', got %q", string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Errorf("expected 2 entries, got %d: %v", entries, names)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: nested directories ───────────────────────────────────────────────
|
||||
|
||||
func TestTarWalk_NestedDirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
subdir := filepath.Join(tmp, "a", "b", "c")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subdir, "deep.txt"), []byte("nested"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tarWalk(tmp, "root", tw); err != nil {
|
||||
t.Fatalf("tarWalk error: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Collect all file paths (not dirs) with content.
|
||||
files := map[string]string{}
|
||||
rdr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := rdr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(hdr.Name, "/") && hdr.Size > 0 {
|
||||
content := make([]byte, hdr.Size)
|
||||
rdr.Read(content)
|
||||
files[hdr.Name] = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
expected := "root/a/b/c/deep.txt"
|
||||
if _, ok := files[expected]; !ok {
|
||||
t.Errorf("expected file %q in tar; got: %v", expected, files)
|
||||
} else if files[expected] != "nested" {
|
||||
t.Errorf("expected content 'nested', got %q", files[expected])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: symlinks are skipped ────────────────────────────────────────────
|
||||
|
||||
func TestTarWalk_SymlinksSkipped(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Create a real file.
|
||||
realPath := filepath.Join(tmp, "real.txt")
|
||||
if err := os.WriteFile(realPath, []byte("real content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a symlink to it.
|
||||
linkPath := filepath.Join(tmp, "link.txt")
|
||||
if err := os.Symlink(realPath, linkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tarWalk(tmp, "prefix", tw); err != nil {
|
||||
t.Fatalf("tarWalk error: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Only real.txt should appear; link.txt should be absent.
|
||||
names := []string{}
|
||||
rdr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := rdr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = append(names, hdr.Name)
|
||||
}
|
||||
|
||||
foundLink := false
|
||||
for _, n := range names {
|
||||
if strings.Contains(n, "link") {
|
||||
foundLink = true
|
||||
}
|
||||
}
|
||||
if foundLink {
|
||||
t.Errorf("symlink should be skipped; got names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: prefix trailing slash is normalized ─────────────────────────────
|
||||
|
||||
func TestTarWalk_PrefixTrailingSlashNormalized(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, "f.txt"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
// Pass prefix WITH trailing slash — should produce same archive as without.
|
||||
if err := tarWalk(tmp, "foo/", tw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The file should be under "foo/", not "foo//".
|
||||
rdr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := rdr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "f.txt") {
|
||||
if strings.Contains(hdr.Name, "//") {
|
||||
t.Errorf("double slash found in path %q — trailing slash not normalized", hdr.Name)
|
||||
}
|
||||
if !strings.HasPrefix(hdr.Name, "foo/") {
|
||||
t.Errorf("expected path to start with 'foo/', got %q", hdr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: prefix = "." emits flat paths ───────────────────────────────────
|
||||
|
||||
func TestTarWalk_PrefixDotEmitsFlatPaths(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
subdir := filepath.Join(tmp, "sub")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tarWalk(tmp, ".", tw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// With prefix ".", paths should NOT start with "./" (filepath.Clean normalizes it).
|
||||
rdr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := rdr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "file.txt") {
|
||||
if strings.HasPrefix(hdr.Name, "./") {
|
||||
t.Errorf("prefix '.' should not emit './' prefix; got %q", hdr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk: walk error propagates ───────────────────────────────────────────
|
||||
|
||||
func TestTarWalk_NonexistentDir(t *testing.T) {
|
||||
nonexistent := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
err := tarWalk(nonexistent, "x", tw)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory, got nil")
|
||||
}
|
||||
}
|
||||
@@ -215,51 +215,6 @@ func TestTarWalk_EmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarWalk_NestedDirs: deeply nested directories produce all intermediate
|
||||
// dir entries plus leaf entries. This exercises the recursive walk.
|
||||
func TestTarWalk_NestedDirs(t *testing.T) {
|
||||
hostDir := t.TempDir()
|
||||
deep := filepath.Join(hostDir, "a", "b", "c")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(deep, "leaf.txt"), []byte("content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := newTarWriter(&buf)
|
||||
if err := tarWalk(hostDir, "configs/plugins/.staging", tw); err != nil {
|
||||
t.Fatalf("tarWalk: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
entries := readTarNames(&buf)
|
||||
// Must include: prefix/, prefix/a/, prefix/a/b/, prefix/a/b/c/, prefix/a/b/c/leaf.txt
|
||||
expected := []string{
|
||||
"configs/plugins/.staging/",
|
||||
"configs/plugins/.staging/a/",
|
||||
"configs/plugins/.staging/a/b/",
|
||||
"configs/plugins/.staging/a/b/c/",
|
||||
"configs/plugins/.staging/a/b/c/leaf.txt",
|
||||
}
|
||||
if len(entries) != len(expected) {
|
||||
t.Errorf("nested dirs: got %d entries; want %d: %v", len(entries), len(expected), entries)
|
||||
}
|
||||
for _, e := range expected {
|
||||
found := false
|
||||
for _, g := range entries {
|
||||
if g == e {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing entry: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarWalk_DirEntryHasTrailingSlash: directory entries must end with '/'
|
||||
// per tar format; tar.Header.Typeflag '5' (dir) must produce "name/" not "name".
|
||||
func TestTarWalk_DirEntryHasTrailingSlash(t *testing.T) {
|
||||
|
||||
@@ -14,8 +14,9 @@ func setupMockDB(t *testing.T) sqlmock.Sqlmock {
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ func setupHibernationMock(t *testing.T) sqlmock.Sqlmock {
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ func setupLivenessTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEveryPatternCompiles pins that every Pattern.regexSource is a
|
||||
// valid Go-RE2 expression. Without this, a bad regex would silently
|
||||
// disable ScanBytes for everything after it (the lazy compile would
|
||||
// set compileErr and ScanBytes would return that error every call).
|
||||
func TestEveryPatternCompiles(t *testing.T) {
|
||||
for _, p := range Patterns {
|
||||
if p.Name == "" {
|
||||
t.Errorf("pattern with empty Name: regex=%q", p.regexSource)
|
||||
}
|
||||
if p.Description == "" {
|
||||
t.Errorf("pattern %q has empty Description", p.Name)
|
||||
}
|
||||
}
|
||||
// Force compile + check error.
|
||||
if _, err := ScanBytes([]byte("placeholder")); err != nil {
|
||||
t.Fatalf("ScanBytes init failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDuplicateNames — a duplicate pattern Name would make the
|
||||
// "first match wins" semantics surprising to readers and any caller
|
||||
// switching on Match.Name (none today but adding the guard is cheap).
|
||||
func TestNoDuplicateNames(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
if seen[p.Name] {
|
||||
t.Errorf("duplicate pattern Name: %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownPatternsAllPresent — pins which specific Name values are
|
||||
// expected. A future refactor that renames or removes one without
|
||||
// updating consumers (CI workflow, runtime pre-commit hook, Files
|
||||
// API Phase 2b backend) would silently widen the leak surface.
|
||||
// Failing here forces the rename to be intentional.
|
||||
func TestKnownPatternsAllPresent(t *testing.T) {
|
||||
expected := []string{
|
||||
"github-pat-classic",
|
||||
"github-app-installation-token",
|
||||
"github-oauth-user-to-server",
|
||||
"github-oauth-user",
|
||||
"github-oauth-refresh",
|
||||
"github-pat-fine-grained",
|
||||
"anthropic-api-key",
|
||||
"openai-project-key",
|
||||
"openai-service-account-key",
|
||||
"minimax-api-key",
|
||||
"slack-token",
|
||||
"aws-access-key-id",
|
||||
"aws-sts-temp-access-key-id",
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
got[p.Name] = true
|
||||
}
|
||||
for _, want := range expected {
|
||||
if !got[want] {
|
||||
t.Errorf("expected pattern %q missing from Patterns slice", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositiveMatches — for each pattern, supply a representative
|
||||
// shape and assert ScanBytes returns a Match with the right Name.
|
||||
// These are TEST FIXTURES, not real credentials — each is the
|
||||
// pattern's prefix + a long-enough trailing run of placeholder chars.
|
||||
// `EXAMPLE` is sprinkled in to make grep-finds in CI logs obviously
|
||||
// fake to a human reader (matches saved memory
|
||||
// feedback_assert_exact_not_substring: tighten by Name not body).
|
||||
func TestPositiveMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
fixture string
|
||||
expectedName string
|
||||
}{
|
||||
{"ghp_EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
|
||||
{"ghs_EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
|
||||
{"gho_EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
|
||||
{"ghu_EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
|
||||
{"ghr_EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
|
||||
{"github_pat_EXAMPLE" + strings.Repeat("1", 80), "github-pat-fine-grained"},
|
||||
{"sk-ant-EXAMPLE" + strings.Repeat("1", 40), "anthropic-api-key"},
|
||||
{"sk-proj-EXAMPLE" + strings.Repeat("1", 40), "openai-project-key"},
|
||||
{"sk-svcacct-EXAMPLE" + strings.Repeat("1", 40), "openai-service-account-key"},
|
||||
{"sk-cp-EXAMPLE" + strings.Repeat("1", 60), "minimax-api-key"},
|
||||
{"xoxb-" + strings.Repeat("a", 25), "slack-token"},
|
||||
{"xoxa-" + strings.Repeat("a", 25), "slack-token"},
|
||||
// AWS regex requires [0-9A-Z]{16} — uppercase + digits only.
|
||||
{"AKIA1234567890ABCDEF", "aws-access-key-id"},
|
||||
{"ASIA1234567890ABCDEF", "aws-sts-temp-access-key-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expectedName, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(tc.fixture))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", tc.fixture, err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatalf("ScanBytes(%q) returned no match — expected %q", tc.fixture, tc.expectedName)
|
||||
}
|
||||
if m.Name != tc.expectedName {
|
||||
t.Errorf("ScanBytes(%q) matched %q; expected %q", tc.fixture, m.Name, tc.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegativeShapes — strings that look credential-adjacent but
|
||||
// shouldn't match (too short, wrong prefix, missing trailing bytes).
|
||||
// Failing here means a pattern is too loose, which would generate
|
||||
// false-positive denial in Files API and false-positive workflow
|
||||
// failures in CI.
|
||||
func TestNegativeShapes(t *testing.T) {
|
||||
cases := []string{
|
||||
// Too-short variants — anchored on the length suffix.
|
||||
"ghp_tooshort",
|
||||
"ghs_alsoshort1234",
|
||||
"github_pat_short",
|
||||
"sk-ant-short",
|
||||
"sk-cp-not-enough-bytes-here",
|
||||
// Looks like one of the prefixes but isn't (different letter).
|
||||
"gha_EXAMPLE_thirty_six_or_more_chars_here_xxx",
|
||||
// Slack family — wrong letter after xox.
|
||||
"xoxz-aaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
// AWS-shaped but wrong length suffix.
|
||||
"AKIATOOSHORT",
|
||||
// Empty / whitespace.
|
||||
"",
|
||||
" ",
|
||||
// Plain prose mentioning the prefix as part of a longer word.
|
||||
"see also `ghp_HOWTO.md` in the repo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", c, err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes(%q) unexpectedly matched %q", c, m.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanString_NoOp — sanity-check ScanString is the zero-copy
|
||||
// wrapper around ScanBytes. Without this, a future refactor that
|
||||
// makes ScanString do its own thing (e.g. accidentally normalise
|
||||
// case) would diverge silently.
|
||||
func TestScanString_NoOp(t *testing.T) {
|
||||
in := "ghp_EXAMPLE111122223333444455556666777788889999"
|
||||
m1, err1 := ScanBytes([]byte(in))
|
||||
if err1 != nil {
|
||||
t.Fatalf("ScanBytes errored: %v", err1)
|
||||
}
|
||||
m2, err2 := ScanString(in)
|
||||
if err2 != nil {
|
||||
t.Fatalf("ScanString errored: %v", err2)
|
||||
}
|
||||
if m1 == nil || m2 == nil {
|
||||
t.Fatalf("expected matches; got bytes=%+v string=%+v", m1, m2)
|
||||
}
|
||||
if m1.Name != m2.Name {
|
||||
t.Errorf("ScanString and ScanBytes returned different Names: %q vs %q", m1.Name, m2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatch_NoRoundtrip — assert the Match struct does NOT include
|
||||
// the matched substring as a field. Adding such a field would
|
||||
// regress the "matched bytes never leave ScanBytes" invariant that
|
||||
// makes this package safe to call from log/UI surfaces. This is a
|
||||
// reflection-light contract test — checks the field names statically.
|
||||
func TestMatch_NoRoundtrip(t *testing.T) {
|
||||
var m Match
|
||||
// If someone adds a `Matched string` (or similar) field, this
|
||||
// test reads as the canonical place to update + reconsider.
|
||||
_ = m.Name
|
||||
_ = m.Description
|
||||
// The two-field shape is part of the public contract; new fields
|
||||
// require deliberation about whether they leak the secret value.
|
||||
}
|
||||
|
||||
// TestCompileError verifies compileAll returns an error when a regex in
|
||||
// Patterns fails to compile. This exercises the error path at
|
||||
// patterns.go:167-171 — currently 0% coverage.
|
||||
//
|
||||
// Approach: swap Patterns with a slice containing an intentionally invalid
|
||||
// regex (unbalanced `[`), reset the package-level compile state
|
||||
// (compiledOnce, compiledPatterns, compileErr), call compileAll directly,
|
||||
// then restore everything. sync.Once is reassignable because it is a
|
||||
// package-level var (not const, not predeclared).
|
||||
func TestCompileError(t *testing.T) {
|
||||
// Save state.
|
||||
origPatterns := Patterns
|
||||
origOnce := compiledOnce
|
||||
origCompiled := compiledPatterns
|
||||
origErr := compileErr
|
||||
defer func() {
|
||||
Patterns = origPatterns
|
||||
compiledOnce = origOnce
|
||||
compiledPatterns = origCompiled
|
||||
compileErr = origErr
|
||||
}()
|
||||
|
||||
// Inject a pattern with an invalid regex (unbalanced bracket).
|
||||
Patterns = []Pattern{{Name: "invalid", Description: "uncompileable", regexSource: "[unclosed"}}
|
||||
|
||||
// Reset compile state so compileAll actually runs (sync.Once is
|
||||
// package-level and reassignable).
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
// Run compileAll directly — it should return an error.
|
||||
compileAll()
|
||||
|
||||
if compileErr == nil {
|
||||
t.Fatal("compileAll() returned nil error for invalid regex '[unclosed' — expected a compile error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanBytes_CompileErr verifies ScanBytes propagates compileErr
|
||||
// when the package has a bad regex. This exercises the error-returning
|
||||
// path at patterns.go:201-203 — currently 0% coverage.
|
||||
//
|
||||
// We reuse the same swap/restore technique as TestCompileError to put
|
||||
// the package into a compile-err state, then call ScanBytes (not
|
||||
// compileAll directly) to verify the error path is reachable from the
|
||||
// public API.
|
||||
func TestScanBytes_CompileErr(t *testing.T) {
|
||||
// Save state.
|
||||
origPatterns := Patterns
|
||||
origOnce := compiledOnce
|
||||
origCompiled := compiledPatterns
|
||||
origErr := compileErr
|
||||
defer func() {
|
||||
Patterns = origPatterns
|
||||
compiledOnce = origOnce
|
||||
compiledPatterns = origCompiled
|
||||
compileErr = origErr
|
||||
}()
|
||||
|
||||
// Inject an invalid regex so ScanBytes' first call triggers compileErr.
|
||||
Patterns = []Pattern{{Name: "bad", Description: "bad", regexSource: "**invalid**"}}
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
_, err := ScanBytes([]byte("anything"))
|
||||
if err == nil {
|
||||
t.Fatal("ScanBytes returned nil error after injecting an invalid pattern — expected a compile error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user