Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f27097a5c8 | |||
| 4980982aea |
@@ -222,20 +222,9 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
|
||||
combined = status.get("state")
|
||||
statuses = status.get("statuses") or []
|
||||
red_states = {"failure", "error"}
|
||||
# Schema asymmetry: top-level combined uses `state`, but per-entry
|
||||
# items in `statuses[]` use `status` in Gitea 1.22.6. Prefer
|
||||
# `status`; fall back to `state` defensively. Verified empirically
|
||||
# 2026-05-12 03:42Z. Pre-rev4 code only read `state` from per-entry
|
||||
# items → failed[] always empty → render_body always showed the
|
||||
# "no per-context entries were in a red state" fallback even when
|
||||
# the combined-state correctly flagged red. See
|
||||
# `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
def _entry_state(s: dict) -> str:
|
||||
return s.get("status") or s.get("state") or ""
|
||||
|
||||
failed = [
|
||||
s for s in statuses
|
||||
if isinstance(s, dict) and _entry_state(s) in red_states
|
||||
if isinstance(s, dict) and s.get("state") in red_states
|
||||
]
|
||||
return (combined in red_states or bool(failed), failed)
|
||||
|
||||
@@ -324,9 +313,7 @@ def render_body(sha: str, failed: list[dict], debug: dict) -> str:
|
||||
else:
|
||||
for s in failed:
|
||||
ctx = s.get("context", "(no context)")
|
||||
# Per-entry key is `status` in Gitea 1.22.6, not `state`
|
||||
# (see _entry_state in is_red). Fallback for forward-compat.
|
||||
state = s.get("status") or s.get("state") or "(no state)"
|
||||
state = s.get("state", "(no state)")
|
||||
url = s.get("target_url") or ""
|
||||
desc = (s.get("description") or "").strip()
|
||||
entry = f"- **{ctx}** — `{state}`"
|
||||
@@ -559,11 +546,7 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
"combined_state": status.get("state"),
|
||||
"failed_contexts": [s.get("context") for s in failed],
|
||||
"all_contexts": [
|
||||
# Per-entry key is `status` in Gitea 1.22.6, not `state`.
|
||||
# Pre-rev4 debug output reported `state: None` for every
|
||||
# context, making run logs useless for triage.
|
||||
{"context": s.get("context"),
|
||||
"state": s.get("status") or s.get("state")}
|
||||
{"context": s.get("context"), "state": s.get("state")}
|
||||
for s in (status.get("statuses") or [])
|
||||
if isinstance(s, dict)
|
||||
],
|
||||
|
||||
@@ -452,18 +452,7 @@ def reap(
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
context = s.get("context") or ""
|
||||
# Schema asymmetry: Gitea 1.22.6 returns the TOP-LEVEL combined
|
||||
# aggregate as `combined.state` but each per-context entry in
|
||||
# `combined.statuses[]` uses the key `status`, NOT `state`.
|
||||
# Prefer `status`; fall back to `state` so a future Gitea
|
||||
# version (or a test fixture written against the wrong key)
|
||||
# still flows through the compensation path. Verified empirically
|
||||
# via direct API probe 2026-05-12 03:42Z:
|
||||
# /repos/.../commits/{sha}/status entries → key is "status".
|
||||
# Pre-rev4 code read "state" only → returned "" → bypassed the
|
||||
# `state != "failure"` guard → compensation path unreachable.
|
||||
# See `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
state = s.get("status") or s.get("state") or ""
|
||||
state = s.get("state") or ""
|
||||
|
||||
# Only `failure` is the bug shape. `error`/`pending`/`success`
|
||||
# left alone — they have other meanings.
|
||||
|
||||
@@ -85,5 +85,4 @@ jobs:
|
||||
REQUIRED_CHECKS: |
|
||||
Secret scan / Scan diff for credential-shaped strings (pull_request)
|
||||
sop-tier-check / tier-check (pull_request)
|
||||
CI / all-required (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
+8
-35
@@ -70,12 +70,10 @@ jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): all required jobs >=98% green on main.
|
||||
# Flip confirmed 2026-05-12 via combined-status check of latest main
|
||||
# commit (all CI jobs green). `all-required` sentinel hard-fails
|
||||
# when this job fails; no Phase 3 suppression needed.
|
||||
# revert: add `continue-on-error: true` back if regressions appear.
|
||||
continue-on-error: false
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after the surfaced defects
|
||||
# (if any) are triaged.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
platform: ${{ steps.check.outputs.platform }}
|
||||
canvas: ${{ steps.check.outputs.canvas }}
|
||||
@@ -126,29 +124,7 @@ jobs:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#664 (interim): re-mask platform-build pending fix-forward. Phase 4
|
||||
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
|
||||
# "green on main 2026-05-12" — the prior continue-on-error: true had
|
||||
# been hiding failing tests in workspace-server/internal/handlers/.
|
||||
# Two distinct failure classes surfaced on 0e5152c3:
|
||||
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
|
||||
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
|
||||
# expectations for queries production has issued since ~2026-04-21
|
||||
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
|
||||
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
|
||||
# Halt cond #3 applies (regression > 7 days → broader sweep).
|
||||
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
|
||||
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
|
||||
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
|
||||
# error message contains "GLOBAL". Production-vs-test contract
|
||||
# collision — needs design call, not mock update.
|
||||
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
|
||||
# This is a sequenced revert→fix→reflip per
|
||||
# feedback_strict_root_only_after_class_a emergency clause — NOT
|
||||
# a permanent re-mask. Re-flip blocked on mc#664 fix-forward landing.
|
||||
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
|
||||
# retain continue-on-error: false; only platform-build regresses.
|
||||
continue-on-error: true # mc#664 fix-forward in flight; re-flip when tests pass
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -295,8 +271,7 @@ jobs:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
@@ -342,8 +317,7 @@ jobs:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
@@ -418,8 +392,7 @@ jobs:
|
||||
name: Python Lint & Test
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
env:
|
||||
WORKSPACE_ID: test
|
||||
defaults:
|
||||
|
||||
@@ -63,7 +63,6 @@ export function DropTargetBadge() {
|
||||
<>
|
||||
{ghostVisible && (
|
||||
<div
|
||||
data-testid="ghost-slot"
|
||||
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
|
||||
style={{
|
||||
left: slotTL.x,
|
||||
@@ -74,7 +73,6 @@ export function DropTargetBadge() {
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-testid="drop-badge"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for DropTargetBadge — floating drag affordance rendered over the
|
||||
* ReactFlow canvas while a workspace node is being dragged onto a parent.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders nothing when dragOverNodeId is null
|
||||
* - Renders nothing when target node not found in store
|
||||
* - Renders nothing when getInternalNode returns null
|
||||
* - Renders ghost slot + badge when valid target is found
|
||||
* - Ghost hidden when slot falls outside parent bounds
|
||||
* - Badge text includes the target workspace name
|
||||
* - Badge positioned via screen-space coordinates from flowToScreenPosition
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DropTargetBadge } from "../DropTargetBadge";
|
||||
|
||||
// ─── Mutable store state — hoisted so vi.mock factory closures capture the ref ─
|
||||
|
||||
let _storeState: {
|
||||
dragOverNodeId: string | null;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
parentId: string | null;
|
||||
measured?: { width: number; height: number };
|
||||
}>;
|
||||
} = {
|
||||
dragOverNodeId: null,
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
const _subscribers = new Set<() => void>();
|
||||
function _notifySubscribers() {
|
||||
for (const fn of _subscribers) fn();
|
||||
}
|
||||
|
||||
const _mockUseCanvasStore = vi.hoisted(() => {
|
||||
const impl = (selector: (s: typeof _storeState) => unknown) => selector(_storeState);
|
||||
return impl;
|
||||
});
|
||||
|
||||
// Module-level mutable impl — setFlowMock() swaps it out per test.
|
||||
let _flowImpl: (arg: { x: number; y: number }) => { x: number; y: number } =
|
||||
({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
|
||||
let _flowToScreenPosition = vi.hoisted(() =>
|
||||
vi.fn((arg: { x: number; y: number }) => _flowImpl(arg)),
|
||||
);
|
||||
|
||||
let _getInternalNode = vi.hoisted(() =>
|
||||
vi.fn<(id: string) => {
|
||||
internals: { positionAbsolute: { x: number; y: number } };
|
||||
measured?: { width: number; height: number };
|
||||
} | null>(() => null),
|
||||
);
|
||||
|
||||
const _mockUseReactFlow = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
getInternalNode: _getInternalNode,
|
||||
flowToScreenPosition: _flowToScreenPosition,
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: _mockUseCanvasStore,
|
||||
}));
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
useReactFlow: _mockUseReactFlow,
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function setStore(state: Partial<typeof _storeState>) {
|
||||
_storeState = { ..._storeState, ...state };
|
||||
_notifySubscribers();
|
||||
}
|
||||
|
||||
// Helper to set per-test flowToScreenPosition mock — replaces _flowImpl.
|
||||
function setFlowMock(impl: (arg: { x: number; y: number }) => { x: number; y: number }) {
|
||||
_flowImpl = impl;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DropTargetBadge — renders nothing when not dragging", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("returns null when dragOverNodeId is null", () => {
|
||||
setStore({ dragOverNodeId: null });
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when target node not found in store nodes array", () => {
|
||||
setStore({ dragOverNodeId: "ws-target", nodes: [] });
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropTargetBadge — renders nothing when getInternalNode is null", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("returns null when getInternalNode returns null (node not in RF viewport)", () => {
|
||||
_getInternalNode.mockReturnValue(null);
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [{ id: "ws-target", data: { name: "Target WS" }, parentId: null }],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropTargetBadge — renders ghost slot + badge for valid drag target", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("renders the drop badge with target name", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
_flowToScreenPosition
|
||||
.mockReturnValueOnce({ x: 500, y: 400 }) // slotTL
|
||||
.mockReturnValueOnce({ x: 900, y: 600 }) // slotBR
|
||||
.mockReturnValueOnce({ x: 700, y: 200 }); // badge
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "SEO Workspace" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByText(/Drop into: SEO Workspace/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ghost slot div via data-testid", () => {
|
||||
// measured.height must be large enough that parentBR.y > slotTL.y=330 so
|
||||
// ghostVisible = (slotTL.y < parentBR.y) is true.
|
||||
// parentBR.y = abs.y + measured.height = 200 + h > 330 → h > 130
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 500 },
|
||||
});
|
||||
// Component calls flowToScreenPosition 5 times (confirmed via debug):
|
||||
// 1) badge {x:210, y:200} -> {x:420, y:400} (badge center)
|
||||
// 2) slotTL {x:116, y:330} -> {x:232, y:660} (slot origin)
|
||||
// 3) slotBR {x:356, y:460} -> {x:712, y:920} (ghost uses this)
|
||||
// 4) parentTL {x:100, y:200} -> {x:200, y:400} (parent origin)
|
||||
// 5) parentBR {x:320, y:320} -> {x:640, y:640} (parent corner)
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
// 5th call: parentBR = abs + {w:220, h:500} = {320, 700}
|
||||
if (x === 320 && y === 700) return { x: 640, y: 1400 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByTestId("ghost-slot")).toBeTruthy();
|
||||
// Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660)
|
||||
expect(screen.getByTestId("ghost-slot").style.left).toBe("232px");
|
||||
expect(screen.getByTestId("ghost-slot").style.top).toBe("660px");
|
||||
expect(screen.getByTestId("ghost-slot").style.width).toBe("480px");
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
|
||||
});
|
||||
|
||||
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
// Set slotBR (3rd call) to be inside parent to hide ghost.
|
||||
// slotBR.x ≤ parentTL.x makes slotBR.x - slotTL.x < 0 → ghostVisible = false.
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 }; // badge (1st call)
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 }; // slotTL (2nd call)
|
||||
if (x === 356 && y === 460) return { x: 150, y: 460 }; // slotBR (3rd): slotBR.x=150 < parentTL.x=200 → hidden
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 }; // parentTL (4th call)
|
||||
if (x === 320 && y === 320) return { x: 640, y: 640 }; // parentBR (5th call)
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Tiny" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
// Badge should still render, ghost should not
|
||||
expect(screen.getByText(/Drop into: Tiny/)).toBeTruthy();
|
||||
expect(screen.queryByTestId("ghost-slot")).toBeNull();
|
||||
});
|
||||
|
||||
it("badge is absolutely positioned with left and top from flowToScreenPosition", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
if (x === 320 && y === 320) return { x: 640, y: 640 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByTestId("drop-badge")).toBeTruthy();
|
||||
// Badge uses 1st call: {x:210,y:200} -> {x:420,y:400}, badge.y = 400-6 = 394
|
||||
expect(screen.getByTestId("drop-badge").style.left).toBe("420px");
|
||||
expect(screen.getByTestId("drop-badge").style.top).toBe("394px");
|
||||
expect(screen.getByText(/Drop into: Target/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -54,14 +54,9 @@ export function MobileChat({
|
||||
// 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
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
|
||||
// for selector equality. A fallback `?? []` creates a new [] reference on
|
||||
// every store update when agentMessages[agentId] is undefined, causing an
|
||||
// infinite re-render loop (React error #185 / Maximum update depth
|
||||
// exceeded). The undefined case is handled by the initializer below.
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
storedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent",
|
||||
text: m.content,
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ActivityTab — activity ledger with live updates, filtering,
|
||||
* expand/collapse, and A2A error hint rendering.
|
||||
*
|
||||
* Covers:
|
||||
* - Loading state
|
||||
* - Error state (network failure)
|
||||
* - Empty state (no activities)
|
||||
* - Activity list rendering (single + multiple)
|
||||
* - Filter bar: 7 filters, active filter highlighted
|
||||
* - Each filter updates the rendered list
|
||||
* - Auto-refresh toggle (Live / Paused)
|
||||
* - Refresh button calls API
|
||||
* - Full Trace button opens ConversationTraceModal
|
||||
* - Duration display in activity rows
|
||||
* - Expand/collapse row details
|
||||
* - A2A rows show source → target name flow
|
||||
* - Error rows styled differently
|
||||
* - Error detail shown when expanded
|
||||
* - getSkills exported function (standalone unit)
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActivityTab } from "../ActivityTab";
|
||||
import type { ActivityEntry } from "@/types/activity";
|
||||
|
||||
const mockApiGet = vi.fn();
|
||||
|
||||
const mockUseSocketEvent = vi.fn();
|
||||
const mockUseWorkspaceName = vi.fn<(id: string | null) => string>((_id: string | null) => "Test Workspace");
|
||||
const mockConversationTraceModal = vi.fn(() => null);
|
||||
const mockConversationTraceModalRender = vi.fn(
|
||||
({ open }: { open: boolean }) => (open ? <div data-testid="trace-modal">Trace</div> : null),
|
||||
);
|
||||
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({
|
||||
useSocketEvent: (...args: unknown[]) => mockUseSocketEvent(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWorkspaceName", () => ({
|
||||
useWorkspaceName: () => mockUseWorkspaceName,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConversationTraceModal", () => ({
|
||||
ConversationTraceModal: (props: { open: boolean; onClose: () => void; workspaceId: string }) =>
|
||||
props.open ? <div data-testid="trace-modal">Trace</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: (...args: unknown[]) => mockApiGet(...args) },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function activity(overrides: Partial<ActivityEntry> = {}): ActivityEntry {
|
||||
return {
|
||||
id: "act-1",
|
||||
workspace_id: "ws-1",
|
||||
activity_type: "agent_log",
|
||||
source_id: null,
|
||||
target_id: null,
|
||||
method: null,
|
||||
summary: null,
|
||||
request_body: null,
|
||||
response_body: null,
|
||||
duration_ms: null,
|
||||
status: "ok",
|
||||
error_detail: null,
|
||||
created_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ActivityTab — loading / error / empty", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockApiGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Loading activity...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error banner when API fails", async () => {
|
||||
mockApiGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no activities", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("No activity recorded yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — list rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders a single activity row", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple activity rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", activity_type: "agent_log" }),
|
||||
activity({ id: "a2", activity_type: "task_update" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
expect(screen.getByText("TASK")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows duration when duration_ms is present", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", duration_ms: 1234, activity_type: "agent_log" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("1234ms")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows summary text when present", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", summary: "Delegated task to SEO Agent", activity_type: "a2a_send" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Delegated task to SEO Agent/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — filter bar", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders all 7 filter buttons", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /all/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /a2a in/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /a2a out/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /tasks/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /skill promo/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /logs/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /errors/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("active filter has aria-pressed=true", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const allBtn = screen.getByRole("button", { name: /all/i });
|
||||
expect(allBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking a filter updates aria-pressed and re-fetches", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
expect(errorsBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
// API was called with ?type=error
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity?type=error");
|
||||
});
|
||||
|
||||
it("clicking All removes the type query param", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// First click a specific filter
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
// Then click All
|
||||
const allBtn = screen.getByRole("button", { name: /all/i });
|
||||
await act(async () => { allBtn.click(); });
|
||||
await flush();
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — auto-refresh toggle", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders Live by default", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Live")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Live toggles to Paused", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const liveBtn = screen.getByText("⟳ Live");
|
||||
await act(async () => { liveBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Paused toggles back to Live", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const liveBtn = screen.getByText("⟳ Live");
|
||||
await act(async () => { liveBtn.click(); });
|
||||
await flush();
|
||||
const pausedBtn = screen.getByText("⟳ Paused");
|
||||
await act(async () => { pausedBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Live")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — refresh button", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Refresh calls the API", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
||||
await act(async () => { refreshBtn.click(); });
|
||||
await flush();
|
||||
// loadActivities called again (second call)
|
||||
expect(mockApiGet.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — Full Trace button", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Full Trace button opens the trace modal", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const traceBtn = screen.getByRole("button", { name: /full trace/i });
|
||||
await act(async () => { traceBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByTestId("trace-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — row expand / collapse", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("row is collapsed by default (shows ▶)", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a row expands it (shows ▼)", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking expanded row collapses it", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); }); // expand
|
||||
await flush();
|
||||
await act(async () => { rowBtn.click(); }); // collapse
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — A2A rows with source/target", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
mockUseWorkspaceName.mockImplementation((id: string | null) => {
|
||||
if (id === "ws-agent-1") return "Alice Agent";
|
||||
if (id === "ws-agent-2") return "Bob Agent";
|
||||
return "Unknown";
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows source → target for a2a_receive rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "a2a_receive",
|
||||
source_id: "ws-agent-1",
|
||||
target_id: "ws-agent-2",
|
||||
method: "message/send",
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Alice Agent")).toBeTruthy();
|
||||
expect(screen.getByText("→")).toBeTruthy();
|
||||
expect(screen.getByText("Bob Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows A2A OUT badge for a2a_send rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "a2a_send",
|
||||
source_id: "ws-agent-1",
|
||||
target_id: "ws-agent-2",
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("A2A OUT")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — error rows", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("error status row renders with ERROR badge", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", activity_type: "error", status: "error" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("ERROR")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("error detail is shown when row is expanded", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "error",
|
||||
status: "error",
|
||||
error_detail: "Connection refused",
|
||||
duration_ms: null,
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("ERROR").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); });
|
||||
await flush();
|
||||
// Text appears twice: collapsed-row preview + expanded detail section
|
||||
expect(screen.getAllByText("Connection refused")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — type badge rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders correct badge text for each type", async () => {
|
||||
const types: ActivityEntry["activity_type"][] = [
|
||||
"a2a_receive", "a2a_send", "task_update", "skill_promotion", "agent_log", "error",
|
||||
];
|
||||
const entries = types.map((t, i) =>
|
||||
activity({ id: `a${i}`, activity_type: t }),
|
||||
);
|
||||
mockApiGet.mockResolvedValue(entries);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("A2A IN")).toBeTruthy();
|
||||
expect(screen.getByText("A2A OUT")).toBeTruthy();
|
||||
expect(screen.getByText("TASK")).toBeTruthy();
|
||||
expect(screen.getByText("PROMO")).toBeTruthy();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
expect(screen.getByText("ERROR")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — count display", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows count with 'activities' label when filter=all", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1" }),
|
||||
activity({ id: "a2" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/2 activities/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows count with filter label when non-all filter selected", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "error" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/1 error entries/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkills — unit", () => {
|
||||
it("returns empty array for null card", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
expect(getSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when skills is not an array", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
expect(getSkills({ name: "test" } as Record<string, unknown>)).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts skill ids and descriptions", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ name: "code-interpreter" },
|
||||
{ id: "analytics" },
|
||||
],
|
||||
};
|
||||
const result = getSkills(card as Record<string, unknown>);
|
||||
expect(result).toEqual([
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ id: "code-interpreter" },
|
||||
{ id: "analytics" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out skills with no id or name", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
const card = { skills: [{ description: "no id" }, { id: "valid" }] };
|
||||
expect(getSkills(card as Record<string, unknown>)).toEqual([{ id: "valid" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for DetailsTab — workspace detail panel with editable fields,
|
||||
* delete/restart workflows, peers list, error display, and section
|
||||
* composition.
|
||||
*
|
||||
* Covers:
|
||||
* - View mode: all rows rendered (name, role, tier, status, URL, etc.)
|
||||
* - Edit mode: name/role/tier fields become editable
|
||||
* - Save workflow: calls PATCH and updates store
|
||||
* - Cancel: reverts fields to original data
|
||||
* - Delete: two-step confirm (confirm button shows alertdialog)
|
||||
* - Delete confirm: calls DELETE and removes node from store
|
||||
* - Restart button: calls POST /restart for failed/degraded/offline
|
||||
* - Error section: shown for failed/degraded with lastSampleError
|
||||
* - Skills section: rendered when agentCard has skills
|
||||
* - Peers section: loads and displays peer list
|
||||
* - Peers section: empty state when offline
|
||||
* - ConsoleModal: opens/closes via button click
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DetailsTab } from "../DetailsTab";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateNodeData = vi.hoisted(() => vi.fn());
|
||||
const mockRemoveSubtree = vi.hoisted(() => vi.fn());
|
||||
const mockSelectNode = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockUseCanvasStore = vi.hoisted(() => {
|
||||
const fn = (selector: (s: {
|
||||
updateNodeData: typeof mockUpdateNodeData;
|
||||
removeSubtree: typeof mockRemoveSubtree;
|
||||
selectNode: typeof mockSelectNode;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
removeSubtree: mockRemoveSubtree,
|
||||
selectNode: mockSelectNode,
|
||||
});
|
||||
return fn;
|
||||
});
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: mockUseCanvasStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/BudgetSection", () => ({
|
||||
BudgetSection: () => <div data-testid="budget-section">BudgetSection</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/WorkspaceUsage", () => ({
|
||||
WorkspaceUsage: () => <div data-testid="workspace-usage">WorkspaceUsage</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConsoleModal", () => ({
|
||||
ConsoleModal: ({ open, onClose }: { open: boolean; onClose: () => void; workspaceId: string; workspaceName: string }) =>
|
||||
open ? (
|
||||
<div role="dialog" data-testid="console-modal">
|
||||
<button onClick={onClose}>Close Console</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const baseData: WorkspaceNodeData = {
|
||||
name: "Test Workspace",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
url: "https://test.molecules.ai",
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
agentCard: null,
|
||||
} as WorkspaceNodeData;
|
||||
|
||||
function data(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
|
||||
return { ...baseData, ...overrides } as WorkspaceNodeData;
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DetailsTab — view mode", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
mockRemoveSubtree.mockReset();
|
||||
mockSelectNode.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders name, role, tier, status, URL, parent rows", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ role: "SEO Specialist", url: "https://example.com" })} />);
|
||||
expect(screen.getByText("Test Workspace")).toBeTruthy();
|
||||
expect(screen.getByText("SEO Specialist")).toBeTruthy();
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
expect(screen.getByText("online")).toBeTruthy();
|
||||
expect(screen.getByText("https://example.com")).toBeTruthy();
|
||||
expect(screen.getByText("root")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Edit button", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders BudgetSection and WorkspaceUsage", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByTestId("budget-section")).toBeTruthy();
|
||||
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for failed status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for offline status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
|
||||
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for degraded status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded" })} />);
|
||||
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render Restart for online status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.queryByRole("button", { name: /restart|retry/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error section for failed status with lastSampleError", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "ModuleNotFoundError: No module named 'requests'" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("details-error-log")).toBeTruthy();
|
||||
expect(screen.getByText(/ModuleNotFoundError/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders error rate for degraded status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded", lastErrorRate: 0.15 })} />);
|
||||
expect(screen.getByText(/15%/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Delete Workspace button in Danger Zone", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — edit mode", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.patch.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clicking Edit shows form fields", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ role: "Agent" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(screen.getByLabelText(/name/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/role/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/tier/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Edit form pre-fills current values", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "My WS", role: "Coder" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect((screen.getByLabelText(/name/i) as HTMLInputElement).value).toBe("My WS");
|
||||
expect((screen.getByLabelText(/role/i) as HTMLInputElement).value).toBe("Coder");
|
||||
});
|
||||
|
||||
it("Save calls PATCH and exits edit mode", async () => {
|
||||
mockApi.patch.mockResolvedValue({});
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "WS" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: "Renamed WS" } });
|
||||
await flush();
|
||||
// Use scoped search: BudgetSection also has a Save button
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(saveBtn);
|
||||
await flush();
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1",
|
||||
expect.objectContaining({ name: "Renamed WS" }),
|
||||
);
|
||||
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", expect.objectContaining({ name: "Renamed WS" }));
|
||||
// Edit fields should no longer be visible
|
||||
expect(screen.queryByLabelText(/name/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("Cancel reverts to view mode without saving", async () => {
|
||||
mockApi.patch.mockResolvedValue({});
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "Original" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: "Changed" } });
|
||||
await flush();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Cancel" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(cancelBtn);
|
||||
await flush();
|
||||
expect(mockApi.patch).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Original")).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/name/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("Save shows error banner on failure", async () => {
|
||||
mockApi.patch.mockRejectedValue(new Error("Server error"));
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(saveBtn);
|
||||
await flush();
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — delete workflow", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.del.mockReset();
|
||||
mockRemoveSubtree.mockReset();
|
||||
mockSelectNode.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clicking Delete shows confirm dialog", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("alertdialog")).toBeTruthy();
|
||||
expect(screen.getByText(/confirm deletion/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirming delete calls DELETE and removes node from store", async () => {
|
||||
mockApi.del.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
// Radix ConfirmDialog uses dispatchEvent with bubbling click
|
||||
const confirmBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Confirm Delete",
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
|
||||
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("cancelling delete returns to view mode", async () => {
|
||||
mockApi.del.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Cancel",
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(cancelBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — restart workflow", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.post.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Restart button calls POST /restart and sets status to provisioning", async () => {
|
||||
mockApi.post.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await flush();
|
||||
expect(mockApi.post).toHaveBeenCalledWith("/workspaces/ws-1/restart", {});
|
||||
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", { status: "provisioning" });
|
||||
});
|
||||
|
||||
it("Restart shows error on failure", async () => {
|
||||
mockApi.post.mockRejectedValue(new Error("Restart failed"));
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
|
||||
await flush();
|
||||
expect(screen.getByText(/restart failed/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — peers section", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads peers from API", async () => {
|
||||
mockApi.get.mockResolvedValue([
|
||||
{ id: "p1", name: "Alice Agent", role: "seo", status: "online", tier: 2 },
|
||||
{ id: "p2", name: "Bob Agent", role: null, status: "offline", tier: 3 },
|
||||
]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
expect(screen.getByText("Alice Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Bob Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'No reachable peers' when list is empty", async () => {
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
expect(screen.getByText("No reachable peers")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows offline message when workspace is not online", async () => {
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "provisioning" })} />);
|
||||
await flush();
|
||||
expect(screen.getByText(/only discoverable while the workspace is online/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking peer name selects that node", async () => {
|
||||
mockApi.get.mockResolvedValue([{ id: "p1", name: "Alice Agent", role: null, status: "online", tier: 2 }]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("Alice Agent"));
|
||||
await flush();
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — skills section", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders skills from agentCard", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ agentCard: { name: "Test Agent", skills: [
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ id: "code-interpreter" },
|
||||
]} as unknown as WorkspaceNodeData["agentCard"] })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("web-search")).toBeTruthy();
|
||||
expect(screen.getByText("Search the web")).toBeTruthy();
|
||||
expect(screen.getByText("code-interpreter")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render Skills section when agentCard is null", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.queryByText("Skills")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — ConsoleModal", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("View console output button opens ConsoleModal", async () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "Traceback..." })}
|
||||
/>,
|
||||
);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
|
||||
await flush();
|
||||
expect(screen.getByTestId("console-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Close button closes ConsoleModal", async () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "Traceback..." })}
|
||||
/>,
|
||||
);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
|
||||
await flush();
|
||||
expect(screen.getByTestId("console-modal")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /close console/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTestId("console-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -189,78 +189,6 @@ def test_is_red_no_statuses(wd_module):
|
||||
assert failed == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-entry vendor-truth key (rev4) — see status-reaper rev4 sibling
|
||||
#
|
||||
# Gitea 1.22.6 returns per-entry items in combined.statuses[] with key
|
||||
# `status`, not `state`. Pre-rev4 code only read `state` → failed[]
|
||||
# was always empty → render_body always emitted the fallback "no
|
||||
# per-context entries were in a red state". These tests use the
|
||||
# canonical Gitea shape to lock the fix in.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_is_red_vendor_truth_status_key_under_pending(wd_module):
|
||||
"""Real Gitea 1.22.6 shape: per-entry uses `status`. A single failed
|
||||
context counts as red even when combined is `pending`. Pre-rev4
|
||||
this returned `(False, [])` because `s.get("state")` was None."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "ci/lint", "status": "success"},
|
||||
{"context": "ci/test", "status": "failure"},
|
||||
{"context": "ci/build", "status": "pending"},
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert [s["context"] for s in failed] == ["ci/test"]
|
||||
|
||||
|
||||
def test_is_red_status_takes_precedence_over_state(wd_module):
|
||||
"""If both keys present (defensive), `status` (vendor truth) wins."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
# `status=failure` is truth even though `state=success` is
|
||||
# stale. Locking in the precedence prevents a hypothetical
|
||||
# future Gitea release that emits both from re-introducing
|
||||
# the bug under a different shape.
|
||||
{"context": "ci/test", "status": "failure", "state": "success"},
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
|
||||
|
||||
def test_is_red_state_only_fallback_still_works(wd_module):
|
||||
"""Backward-compat: a legacy fixture or future Gitea variant that
|
||||
only emits `state` still trips the red detection via the fallback
|
||||
chain. Keeps pre-rev4 fixtures green during the rev4 rollout."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "ci/test", "state": "failure"}, # legacy shape
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
|
||||
|
||||
def test_render_body_uses_status_key_for_per_entry_state(wd_module):
|
||||
"""render_body must surface the per-entry `status` value in the
|
||||
issue body. Pre-rev4 it read `state` (always None on real Gitea) →
|
||||
every issue body said `(no state)`, defeating the diagnostic."""
|
||||
failed = [
|
||||
{"context": "ci/test", "status": "failure",
|
||||
"target_url": "https://example.test/run/1",
|
||||
"description": "broke"},
|
||||
]
|
||||
body = wd_module.render_body("deadbeefcafe1234", failed, {})
|
||||
assert "`failure`" in body, (
|
||||
"render_body did not surface per-entry status — likely still "
|
||||
"reading `state` key only (rev1-3 bug)."
|
||||
)
|
||||
assert "(no state)" not in body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Happy path — main is green, no issue created
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -544,156 +544,6 @@ def test_reap_unparseable_push_context_preserved(sr_module, monkeypatch):
|
||||
assert counters["preserved_unparseable"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-context status-key vendor-truth (rev4)
|
||||
#
|
||||
# Gitea 1.22.6 returns commit-status entries with key `status` per entry,
|
||||
# NOT `state`. The TOP-LEVEL combined aggregate uses `state`. This schema
|
||||
# asymmetry caused rev1-3 to take the compensation path 0 times despite
|
||||
# triggering on real failures: `s.get("state")` returned None → state
|
||||
# evaluated to "" → `"" != "failure"` guard preserved every entry.
|
||||
#
|
||||
# These tests explicitly use the vendor-truth shape (`status` per entry),
|
||||
# proving the rev4 fix routes the failure entry through compensation.
|
||||
# Fixtures in rev1-3 tests above use `state` (the pre-fix bug shape) —
|
||||
# we keep them for backward-compat coverage via the fallback in
|
||||
# `s.get("status") or s.get("state")`, but the canonical Gitea shape
|
||||
# uses `status`. Logged under
|
||||
# `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_reap_per_context_uses_status_key_not_state(sr_module, monkeypatch):
|
||||
"""Empirical Gitea 1.22.6 shape: per-entry uses `status`, top-level
|
||||
uses `state`. The rev4 fix MUST detect failure via `status`."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"staging-smoke": False} # no push trigger → Class-O
|
||||
# Real Gitea-shaped response: top-level `state`, per-entry `status`.
|
||||
# No `state` key on the per-entry item.
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"status": "failure", # ← vendor-truth key
|
||||
"target_url": "https://example.test/run/1",
|
||||
"description": "smoke job failed",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
# The bug-class assertion: pre-rev4 this would have been 0, with
|
||||
# preserved_non_failure=1. Rev4 reads `status` → routes to compensate.
|
||||
assert counters["compensated"] == 1, (
|
||||
"Compensation path unreachable: status-reaper still reads `state` "
|
||||
"instead of `status` on per-entry combined.statuses[] items "
|
||||
"(rev1-3 bug)."
|
||||
)
|
||||
assert counters["preserved_non_failure"] == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "POST"
|
||||
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
|
||||
|
||||
|
||||
def test_reap_per_context_status_key_takes_precedence_over_state(
|
||||
sr_module, monkeypatch
|
||||
):
|
||||
"""Defensive: if both `status` and `state` are present (e.g. a
|
||||
hypothetical Gitea version emits both), `status` (the canonical
|
||||
Gitea 1.22.6 key) wins. Guards against a future regression where
|
||||
a fixture or future Gitea release emits stale `state="success"`
|
||||
while `status="failure"` is the truth."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"staging-smoke": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
# Both keys present — vendor-truth `status` MUST win.
|
||||
"status": "failure",
|
||||
"state": "success",
|
||||
"target_url": "https://example.test/run/2",
|
||||
"description": "smoke job failed",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 1
|
||||
assert counters["preserved_non_failure"] == 0
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_reap_per_context_state_only_fallback(sr_module, monkeypatch):
|
||||
"""Backward-compat: a test fixture or older Gitea variant that emits
|
||||
only `state` (no `status`) must still flow through compensation.
|
||||
Belt-and-suspenders against future fixture drift. Keeps rev1-3
|
||||
`state`-using fixtures green."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"staging-smoke": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"state": "failure", # legacy fixture shape only
|
||||
"target_url": "https://example.test/run/3",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_reap_per_context_missing_both_keys_preserves(sr_module, monkeypatch):
|
||||
"""A per-entry item lacking BOTH `status` and `state` must be
|
||||
preserved (counted under preserved_non_failure). This is the only
|
||||
correctly-behaving leg of the pre-rev4 bug — exercising it ensures
|
||||
the fallback chain doesn't accidentally over-compensate on
|
||||
malformed entries."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {"staging-smoke": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
# No status, no state — neither key present.
|
||||
"target_url": "https://example.test/run/4",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_non_failure"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ApiError propagation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user