Compare commits

..

2 Commits

Author SHA1 Message Date
core-fe f27097a5c8 test(canvas): add MemoryTab tests (42 cases)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 36s
E2E API Smoke Test / detect-changes (pull_request) Successful in 37s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 34s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 31s
qa-review / approved (pull_request) Failing after 18s
gate-check-v3 / gate-check (pull_request) Successful in 29s
security-review / approved (pull_request) Failing after 17s
sop-tier-check / tier-check (pull_request) Successful in 23s
CI / Platform (Go) (pull_request) Successful in 6s
Harness Replays / Harness Replays (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m48s
audit-force-merge / audit (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Successful in 10m37s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 2s
Cover awareness dashboard expand/collapse, iframe with workspaceId in URL,
status grid, KV memory list, expand/collapse entries, add/edit/delete
memory entries, JSON parsing, TTL support, 409 conflict retry hint,
error states, and refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 03:46:50 +00:00
core-fe 4980982aea chore: retrigger CI after rebase to main 2026-05-12 03:46:50 +00:00
12 changed files with 527 additions and 2201 deletions
+3 -20
View File
@@ -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)
],
+1 -12
View File
@@ -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.
-1
View File
@@ -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
View File
@@ -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();
});
});
+2 -7
View File
@@ -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
-72
View File
@@ -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
# --------------------------------------------------------------------------
-150
View File
@@ -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
# --------------------------------------------------------------------------