mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 attempt-3 Phase 5 P5-7: topology terminal pagination + pulsing-ring
Per topology-terminal-view.md L13-L80: real pinned-card grid replaces the
TerminalGridPlaceholder for host / rig / pod scopes. V1 reuses preserved
SessionPreviewPane primitive (per code-map AFTER tree); V2 replaces with
xterm.js + tmux-attach (out of V1 scope).
NEW packages/ui/src/components/topology/TopologyTerminalView.tsx:
- TopologyTerminalView({ scope: "host"|"rig"|"pod", rigId?, podName? }).
- Host scope: iterates useRigSummary rigs, renders one RigTerminalSection
per rig (each section uses useNodeInventory).
- Rig + Pod scopes: single useNodeInventory(rigId); pod filters by
podNamespace/podId match against podName.
- TerminalGrid component with safe-N=12 pagination per L70-L80
("showing 12 of N · show all N" toggle).
- SeatTerminalCard wraps each seat in border + SessionPreviewPane;
active-state seats (agentActivity.state === "running") get
data-active='true' + .terminal-card-active class for pulsing-ring.
- Responsive grid: 1 col mobile / 2 cols md / 3 cols lg.
ADDED globals.css @keyframes terminal-card-active-frames + selector per
L47 LOAD-BEARING pulsing-ring contract. 2.2s ease-in-out infinite;
secondary-purple halo (rgba(167, 139, 250, 0.30) at 50%; transparent at
0/100%). Honored by prefers-reduced-motion: reduce block.
REFACTORED ScopePages.tsx: HostScopePage / RigScopePage / PodScopePage
'terminal' tab now mounts <TopologyTerminalView scope=... />; old
TerminalGridPlaceholder removed.
NEW packages/ui/test/p5-topology-terminal.test.tsx (8 tests):
- Rig scope grid renders one card per agent seat; data-active +
terminal-card-active class on running seats; idle seats neutral.
- safe-N pagination: 15 seats → 12 shown by default; toggle reveals all.
- Pod scope filters seats by podName.
- Empty states (no seats / no rigs).
- CSS-source-assertion (ritual #7 pseudo-element-paint contract):
globals.css declares @keyframes terminal-card-active-frames +
.terminal-card-active selector + reduced-motion suppression.
Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-topology-terminal: 8/8 pass
- pnpm -F @openrig/ui build: clean
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
4e67cf660c
commit
3981ad187e
4 files changed
+426
-15
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
type TopologySeatScopeTab,
|
||||
} from "./TopologyViewModeTabs.js";
|
||||
import { TopologyTableView } from "./TopologyTableView.js";
|
||||
import { TopologyTerminalView } from "./TopologyTerminalView.js";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { RigGraph } from "../RigGraph.js";
|
||||
@@ -78,18 +79,9 @@ function ScopeShell({
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalGridPlaceholder({ scope }: { scope: string }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
label="TERMINAL GRID"
|
||||
description={`${scope} terminals — pinned-card grid (Phase 5 polish; safe-N pagination per topology-terminal-view.md L70-80).`}
|
||||
variant="card"
|
||||
testId="topology-terminal-placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// V1 attempt-3 Phase 5 P5-7: TopologyTerminalView replaces the placeholder
|
||||
// with a real safe-N=12 paginated pinned-card grid + pulsing-ring on active
|
||||
// terminals (per topology-terminal-view.md L47/L60-65/L70-80).
|
||||
|
||||
export function HostScopePage() {
|
||||
const [active, setActive] = useState<TopologyHostScopeTab>("graph");
|
||||
@@ -122,7 +114,7 @@ export function HostScopePage() {
|
||||
<TopologyTableView />
|
||||
</div>
|
||||
) : null}
|
||||
{active === "terminal" ? <TerminalGridPlaceholder scope="Host" /> : null}
|
||||
{active === "terminal" ? <TopologyTerminalView scope="host" /> : null}
|
||||
</ScopeShell>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +142,7 @@ export function RigScopePage() {
|
||||
<TopologyTableView rigIdScope={rigId} />
|
||||
</div>
|
||||
) : null}
|
||||
{active === "terminal" ? <TerminalGridPlaceholder scope="Rig" /> : null}
|
||||
{active === "terminal" ? <TopologyTerminalView scope="rig" rigId={rigId} /> : null}
|
||||
{active === "overview" ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
@@ -186,7 +178,9 @@ export function PodScopePage() {
|
||||
<TopologyTableView rigIdScope={rigId} />
|
||||
</div>
|
||||
) : null}
|
||||
{active === "terminal" ? <TerminalGridPlaceholder scope="Pod" /> : null}
|
||||
{active === "terminal" ? (
|
||||
<TopologyTerminalView scope="pod" rigId={rigId} podName={podName} />
|
||||
) : null}
|
||||
{active === "overview" ? (
|
||||
<div className="p-6">
|
||||
<EmptyState label="POD OVERVIEW" description="Pod detail (Phase 5)." variant="card" />
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// V1 attempt-3 Phase 5 P5-7 — Topology terminal grid view.
|
||||
//
|
||||
// Per topology-terminal-view.md L13-L80: pinned-card grid where each card
|
||||
// is a transcript-tail preview of an agent's terminal output. V1 reuses
|
||||
// the existing SessionPreviewPane primitive (preserved per code-map AFTER
|
||||
// tree). V2 ships interactive xterm.js + tmux-attach (out of scope here).
|
||||
//
|
||||
// Key behaviors (canon-load-bearing):
|
||||
// - Pulsing-ring on cards whose agent is "running" (active recently)
|
||||
// per topology-terminal-view.md L47 — at-a-glance scan signal
|
||||
// ("humans must be able to spot which agents to attend to without
|
||||
// reading every card"). CSS keyframe is pseudo-element-paint
|
||||
// (jsdom-incompatible) so a CSS-source-assertion test guards it.
|
||||
// - safe-N pagination at host scope (L70-L80): default 12 cards;
|
||||
// "show all N" toggle for operators who need the full grid.
|
||||
// - Polling cadence: SessionPreviewPane uses useSessionPreview which
|
||||
// respects the configured ui.preview.refresh_interval_seconds
|
||||
// (default 3s). Per L60-L65, larger scopes = lower frequency; the
|
||||
// existing config covers it.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRigSummary } from "../../hooks/useRigSummary.js";
|
||||
import { useNodeInventory, type NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
|
||||
import { displayAgentName } from "../../lib/display-name.js";
|
||||
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const SAFE_N = 12;
|
||||
|
||||
interface TopologyTerminalViewProps {
|
||||
scope: "host" | "rig" | "pod";
|
||||
/** Required for rig + pod scopes. */
|
||||
rigId?: string;
|
||||
/** Required for pod scope. */
|
||||
podName?: string;
|
||||
}
|
||||
|
||||
function isActiveRunning(seat: NodeInventoryEntry): boolean {
|
||||
// Per topology-terminal-view.md L47: pulsing-ring on cards for "active"
|
||||
// agents (output emitted within polling window). The PL-019 agentActivity
|
||||
// summary's "running" state is the closest available proxy.
|
||||
return seat.agentActivity?.state === "running";
|
||||
}
|
||||
|
||||
function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
|
||||
const sessionName = seat.canonicalSessionName ?? seat.logicalId;
|
||||
const active = isActiveRunning(seat);
|
||||
const memberName = displayAgentName(seat.logicalId);
|
||||
return (
|
||||
<div
|
||||
data-testid={`terminal-card-${seat.rigId}-${seat.logicalId}`}
|
||||
data-active={active ? "true" : "false"}
|
||||
className={cn(
|
||||
"border bg-white/40 p-2 flex flex-col gap-2",
|
||||
active
|
||||
? "border-secondary terminal-card-active"
|
||||
: "border-outline-variant",
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-900 truncate">
|
||||
{memberName}
|
||||
</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-wide text-on-surface-variant shrink-0">
|
||||
{seat.runtime?.replace("claude-code", "claude") ?? "?"}
|
||||
{typeof seat.contextUsage?.usedPercentage === "number"
|
||||
? ` · ${seat.contextUsage.usedPercentage}%`
|
||||
: ""}
|
||||
</span>
|
||||
</header>
|
||||
<SessionPreviewPane
|
||||
sessionName={sessionName}
|
||||
lines={20}
|
||||
testIdPrefix={`terminal-preview-${seat.rigId}-${seat.logicalId}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalGrid({
|
||||
seats,
|
||||
emptyLabel,
|
||||
emptyDescription,
|
||||
}: {
|
||||
seats: NodeInventoryEntry[];
|
||||
emptyLabel: string;
|
||||
emptyDescription: string;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const visible = showAll ? seats : seats.slice(0, SAFE_N);
|
||||
const hasMore = seats.length > SAFE_N;
|
||||
|
||||
if (seats.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
label={emptyLabel}
|
||||
description={emptyDescription}
|
||||
variant="card"
|
||||
testId="topology-terminal-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="topology-terminal-grid" className="space-y-3">
|
||||
<div className="font-mono text-[9px] text-on-surface-variant flex items-center justify-between">
|
||||
<span data-testid="topology-terminal-count">
|
||||
showing {visible.length} of {seats.length} terminal{seats.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="topology-terminal-show-toggle"
|
||||
onClick={() => setShowAll((s) => !s)}
|
||||
className="px-2 py-0.5 border border-outline-variant font-mono text-[9px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
>
|
||||
{showAll ? `show first ${SAFE_N}` : `show all ${seats.length}`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((seat) => (
|
||||
<SeatTerminalCard
|
||||
key={`${seat.rigId}-${seat.logicalId}`}
|
||||
seat={seat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RigTerminalSection({ rigId, rigName }: { rigId: string; rigName: string }) {
|
||||
const { data: nodes } = useNodeInventory(rigId);
|
||||
const seats = useMemo(
|
||||
() => (nodes ?? []).filter((n) => n.nodeKind !== "infrastructure"),
|
||||
[nodes],
|
||||
);
|
||||
if (seats.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
data-testid={`topology-terminal-rig-${rigId}`}
|
||||
className="border-t border-outline-variant pt-4 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<SectionHeader tone="muted">{rigName}</SectionHeader>
|
||||
<div className="mt-2">
|
||||
<TerminalGrid
|
||||
seats={seats}
|
||||
emptyLabel="NO SEATS"
|
||||
emptyDescription={`No agent seats in ${rigName}.`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopologyTerminalView({ scope, rigId, podName }: TopologyTerminalViewProps) {
|
||||
const { data: rigs } = useRigSummary();
|
||||
const { data: rigNodes } = useNodeInventory(scope !== "host" ? (rigId ?? null) : null);
|
||||
|
||||
if (scope === "host") {
|
||||
if (!rigs || rigs.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
label="NO RIGS"
|
||||
description="No rigs registered. Register a rig to see terminals at host scope."
|
||||
variant="card"
|
||||
testId="topology-terminal-empty"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="topology-terminal-host" className="p-6 space-y-6">
|
||||
{rigs.map((r) => (
|
||||
<RigTerminalSection key={r.id} rigId={r.id} rigName={r.name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rig + Pod scopes share the same single-rig data source; pod filters by
|
||||
// podName.
|
||||
const seatsAll = (rigNodes ?? []).filter((n) => n.nodeKind !== "infrastructure");
|
||||
const seats =
|
||||
scope === "pod" && podName
|
||||
? seatsAll.filter((s) => (s.podNamespace ?? s.podId) === podName)
|
||||
: seatsAll;
|
||||
|
||||
return (
|
||||
<div data-testid={`topology-terminal-${scope}`} className="p-6">
|
||||
<TerminalGrid
|
||||
seats={seats}
|
||||
emptyLabel="NO SEATS"
|
||||
emptyDescription={
|
||||
scope === "pod"
|
||||
? `No agent seats in pod ${podName}.`
|
||||
: "No agent seats in this rig."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -265,6 +265,19 @@
|
||||
animation: activity-pulse-running-frames 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* V1 attempt-3 Phase 5 P5-7 — Topology terminal grid pulsing-ring per
|
||||
topology-terminal-view.md L47. Cards for "active" agents (output
|
||||
emitted within polling window) get a pulsing ring around the card;
|
||||
idle cards are neutral. At-a-glance scan signal so the human can
|
||||
spot which agents to attend to without reading every card. */
|
||||
@keyframes terminal-card-active-frames {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(167, 139, 250, 0.0); }
|
||||
50% { box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.30); }
|
||||
}
|
||||
.terminal-card-active {
|
||||
animation: terminal-card-active-frames 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* PL-019 item 6 — edge animation. One-shot ~1.2s emphasis on edges
|
||||
between source-seat and dest-seat when a queue event fires. React Flow
|
||||
renders edges as SVG <path> wrapped in .react-flow__edge; we toggle
|
||||
@@ -297,6 +310,7 @@
|
||||
.shimmer,
|
||||
.animate-pulse-tactical,
|
||||
.activity-pulse-running,
|
||||
.terminal-card-active,
|
||||
.edge-just-fired {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// V1 attempt-3 Phase 5 P5-7 — Topology terminal grid (safe-N + pulsing-ring).
|
||||
//
|
||||
// Coverage:
|
||||
// - safe-N pagination: with > 12 seats, only 12 cards render by default;
|
||||
// "show all N" toggle reveals the rest.
|
||||
// - Pulsing-ring: active seats render with terminal-card-active class
|
||||
// (CSS keyframe is paint-only — pseudo-element-paint test contract
|
||||
// ritual #7; CSS-source-assertion guards the @keyframes rule).
|
||||
// - data-active attribute reflects activity state.
|
||||
// - Empty state when no seats.
|
||||
// - Pod scope filters by podName.
|
||||
//
|
||||
// Pseudo-element-paint contract: jsdom can't render @keyframes. The
|
||||
// CSS-source-assertion test reads globals.css and asserts the
|
||||
// @keyframes terminal-card-active-frames rule + .terminal-card-active
|
||||
// selector are present — guards the at-a-glance scan signal from being
|
||||
// silently removed in a refactor.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { TopologyTerminalView } from "../src/components/topology/TopologyTerminalView.js";
|
||||
import type { NodeInventoryEntry } from "../src/hooks/useNodeInventory.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function withQueryClient(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeSeat(opts: {
|
||||
rigId?: string;
|
||||
logicalId: string;
|
||||
pod?: string;
|
||||
active?: boolean;
|
||||
}): NodeInventoryEntry {
|
||||
return {
|
||||
rigId: opts.rigId ?? "rig-1",
|
||||
rigName: "test-rig",
|
||||
logicalId: opts.logicalId,
|
||||
podId: opts.pod ?? "default",
|
||||
podNamespace: opts.pod ?? "default",
|
||||
canonicalSessionName: `${opts.logicalId}@test-rig`,
|
||||
nodeKind: "agent",
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready",
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
agentActivity: opts.active
|
||||
? {
|
||||
state: "running",
|
||||
reason: "test",
|
||||
evidenceSource: "test",
|
||||
sampledAt: "2026-05-06T18:00:00Z",
|
||||
}
|
||||
: { state: "idle", reason: "test", evidenceSource: "test", sampledAt: "2026-05-06T18:00:00Z" },
|
||||
};
|
||||
}
|
||||
|
||||
function setupFetch(opts: {
|
||||
rigs?: Array<{ id: string; name: string }>;
|
||||
seatsByRig?: Record<string, NodeInventoryEntry[]>;
|
||||
}) {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/rigs/summary")) {
|
||||
return new Response(JSON.stringify(opts.rigs ?? []));
|
||||
}
|
||||
const m = url.match(/\/api\/rigs\/([^/]+)\/nodes/);
|
||||
if (m) {
|
||||
const rigId = decodeURIComponent(m[1]!);
|
||||
return new Response(JSON.stringify(opts.seatsByRig?.[rigId] ?? []));
|
||||
}
|
||||
// SessionPreviewPane fetches /api/preview/session/:name etc.
|
||||
return new Response(JSON.stringify({
|
||||
sessionName: "test", content: "", lines: 0, capturedAt: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
describe("TopologyTerminalView P5-7 grid", () => {
|
||||
it("rig scope: renders one card per seat under safe-N=12", async () => {
|
||||
const seats = [
|
||||
makeSeat({ logicalId: "orch.lead" }),
|
||||
makeSeat({ logicalId: "review.lead" }),
|
||||
makeSeat({ logicalId: "driver.impl", active: true }),
|
||||
];
|
||||
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
|
||||
const { findByTestId, container } = withQueryClient(
|
||||
<TopologyTerminalView scope="rig" rigId="rig-1" />,
|
||||
);
|
||||
expect(await findByTestId("topology-terminal-grid")).toBeTruthy();
|
||||
expect(await findByTestId("terminal-card-rig-1-orch.lead")).toBeTruthy();
|
||||
expect(await findByTestId("terminal-card-rig-1-driver.impl")).toBeTruthy();
|
||||
// Active seat carries data-active='true' AND the pulse class.
|
||||
const driverCard = container.querySelector(
|
||||
"[data-testid='terminal-card-rig-1-driver.impl']",
|
||||
);
|
||||
expect(driverCard?.getAttribute("data-active")).toBe("true");
|
||||
expect(driverCard?.className).toMatch(/terminal-card-active/);
|
||||
// Idle seat does NOT carry the pulse class.
|
||||
const idleCard = container.querySelector(
|
||||
"[data-testid='terminal-card-rig-1-orch.lead']",
|
||||
);
|
||||
expect(idleCard?.getAttribute("data-active")).toBe("false");
|
||||
expect(idleCard?.className ?? "").not.toMatch(/terminal-card-active/);
|
||||
});
|
||||
|
||||
it("safe-N pagination: with > 12 seats, default shows 12; toggle reveals all", async () => {
|
||||
const seats: NodeInventoryEntry[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
seats.push(makeSeat({ logicalId: `seat${i.toString().padStart(2, "0")}` }));
|
||||
}
|
||||
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
|
||||
const { findByTestId, container } = withQueryClient(
|
||||
<TopologyTerminalView scope="rig" rigId="rig-1" />,
|
||||
);
|
||||
await findByTestId("topology-terminal-grid");
|
||||
// Default: 12 cards visible.
|
||||
expect(
|
||||
container.querySelectorAll("[data-testid^='terminal-card-rig-1-']").length,
|
||||
).toBe(12);
|
||||
expect((await findByTestId("topology-terminal-count")).textContent).toContain("12 of 15");
|
||||
// Toggle: show all.
|
||||
const toggle = await findByTestId("topology-terminal-show-toggle");
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelectorAll("[data-testid^='terminal-card-rig-1-']").length,
|
||||
).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
it("pod scope filters seats by podName", async () => {
|
||||
const seats = [
|
||||
makeSeat({ logicalId: "orch.lead", pod: "orch" }),
|
||||
makeSeat({ logicalId: "driver.impl", pod: "implementation" }),
|
||||
makeSeat({ logicalId: "qa.codex", pod: "implementation" }),
|
||||
];
|
||||
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
|
||||
const { findByTestId, container } = withQueryClient(
|
||||
<TopologyTerminalView scope="pod" rigId="rig-1" podName="implementation" />,
|
||||
);
|
||||
await findByTestId("topology-terminal-grid");
|
||||
expect(container.querySelector("[data-testid='terminal-card-rig-1-orch.lead']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='terminal-card-rig-1-driver.impl']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='terminal-card-rig-1-qa.codex']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("empty state when scope has no agent seats", async () => {
|
||||
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": [] } });
|
||||
const { findByTestId } = withQueryClient(
|
||||
<TopologyTerminalView scope="rig" rigId="rig-1" />,
|
||||
);
|
||||
expect(await findByTestId("topology-terminal-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("host scope with no rigs renders empty state", async () => {
|
||||
setupFetch({ rigs: [] });
|
||||
const { findByTestId } = withQueryClient(<TopologyTerminalView scope="host" />);
|
||||
expect(await findByTestId("topology-terminal-empty")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("globals.css pulsing-ring CSS contract (ritual #7 pseudo-element-paint)", () => {
|
||||
const cssPath = path.resolve(__dirname, "../src/globals.css");
|
||||
it("globals.css declares @keyframes terminal-card-active-frames", () => {
|
||||
const src = readFileSync(cssPath, "utf8");
|
||||
expect(src).toMatch(/@keyframes\s+terminal-card-active-frames/);
|
||||
});
|
||||
it("globals.css declares .terminal-card-active selector binding the keyframes", () => {
|
||||
const src = readFileSync(cssPath, "utf8");
|
||||
expect(src).toMatch(/\.terminal-card-active\s*\{[^}]*animation:\s*terminal-card-active-frames/);
|
||||
});
|
||||
it("globals.css honors prefers-reduced-motion for terminal-card-active", () => {
|
||||
const src = readFileSync(cssPath, "utf8");
|
||||
// Reduced-motion block must include .terminal-card-active so the pulse
|
||||
// animation is suppressed for users with motion-sensitivity preferences.
|
||||
const reducedMotion = src.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\}\s*\}/);
|
||||
expect(reducedMotion).not.toBeNull();
|
||||
expect(reducedMotion![0]).toMatch(/\.terminal-card-active/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user