b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
160 lines
7.1 KiB
TypeScript
160 lines
7.1 KiB
TypeScript
/**
|
|
* @file StatusBadge.test.tsx
|
|
* @description Unit tests for the StatusBadge component, which includes AgentStatusBadge and SessionStatusBadge. These components are responsible for displaying the status of agents and sessions in the dashboard. The tests cover rendering of different statuses, application of pulse animation based on status, respect for explicit pulse overrides, and the awaiting-reason suffix (icon + short label + hover tooltip) that explains WHY a row is in the Waiting state. The tests use React Testing Library and Vitest for assertions and mocking.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { AgentStatusBadge, SessionStatusBadge } from "../StatusBadge";
|
|
|
|
describe("AgentStatusBadge", () => {
|
|
it("should render waiting status", () => {
|
|
render(<AgentStatusBadge status="waiting" />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render working status", () => {
|
|
render(<AgentStatusBadge status="working" />);
|
|
expect(screen.getByText("Working")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render completed status", () => {
|
|
render(<AgentStatusBadge status="completed" />);
|
|
expect(screen.getByText("Completed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render error status", () => {
|
|
render(<AgentStatusBadge status="error" />);
|
|
expect(screen.getByText("Error")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should apply pulse animation for working status by default", () => {
|
|
const { container } = render(<AgentStatusBadge status="working" />);
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
});
|
|
|
|
it("should not apply pulse for connected status (now working - has pulse)", () => {
|
|
const { container } = render(<AgentStatusBadge status="working" />);
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
});
|
|
|
|
it("should apply pulse animation for waiting status by default", () => {
|
|
const { container } = render(<AgentStatusBadge status="waiting" />);
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
});
|
|
|
|
it("should respect explicit pulse=false override", () => {
|
|
const { container } = render(<AgentStatusBadge status="working" pulse={false} />);
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should respect explicit pulse=true override", () => {
|
|
const { container } = render(<AgentStatusBadge status="waiting" pulse={true} />);
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render waiting status with yellow dot and pulse by default", () => {
|
|
const { container } = render(<AgentStatusBadge status="waiting" />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("SessionStatusBadge", () => {
|
|
it("should render active status", () => {
|
|
render(<SessionStatusBadge status="active" />);
|
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render completed status", () => {
|
|
render(<SessionStatusBadge status="completed" />);
|
|
expect(screen.getByText("Completed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render error status", () => {
|
|
render(<SessionStatusBadge status="error" />);
|
|
expect(screen.getByText("Error")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render abandoned status", () => {
|
|
render(<SessionStatusBadge status="abandoned" />);
|
|
expect(screen.getByText("Abandoned")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render waiting status with pulsing yellow dot", () => {
|
|
const { container } = render(<SessionStatusBadge status="waiting" />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
const dot = container.querySelector(".animate-pulse-dot");
|
|
expect(dot).toBeInTheDocument();
|
|
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("awaiting-reason suffix", () => {
|
|
it("renders the reason label next to Waiting on AgentStatusBadge", () => {
|
|
render(<AgentStatusBadge status="waiting" reason="notification" />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
expect(screen.getByText("Needs input")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the reason label next to Waiting on SessionStatusBadge", () => {
|
|
render(<SessionStatusBadge status="waiting" reason="stop" />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
expect(screen.getByText("Turn done")).toBeInTheDocument();
|
|
});
|
|
|
|
it("ignores the reason on non-waiting statuses", () => {
|
|
render(<AgentStatusBadge status="working" reason="notification" />);
|
|
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
|
render(<SessionStatusBadge status="active" reason="stop" />);
|
|
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders no suffix when reason is null/omitted", () => {
|
|
render(<AgentStatusBadge status="waiting" reason={null} />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the full reason description in a tooltip on hover", () => {
|
|
const { container } = render(<AgentStatusBadge status="waiting" reason="interrupted" />);
|
|
expect(screen.getByText("Interrupted")).toBeInTheDocument();
|
|
// Tip attaches its handlers to the wrapper element and portals the tooltip
|
|
// body into document.body.
|
|
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
|
|
expect(screen.getByText(/The last turn was interrupted/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("marks urgent reasons with the hotter amber tint", () => {
|
|
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
|
expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
|
|
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
|
expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
|
const { container } = render(
|
|
<AgentStatusBadge status="waiting" reason="notification" compact />
|
|
);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
|
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
|
|
expect(screen.getByText(/Blocked on a permission prompt/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("compact mode works on SessionStatusBadge too", () => {
|
|
render(<SessionStatusBadge status="waiting" reason="stop" compact />);
|
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
|
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
|
});
|
|
});
|