@a5c-ai/babysitter-observer-dashboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { render, screen } from "@/test/test-utils";
|
|
2
|
+
import { CatchUpBanner } from "../catch-up-banner";
|
|
3
|
+
import type { CatchUpState } from "@/hooks/use-batched-updates";
|
|
4
|
+
|
|
5
|
+
describe("CatchUpBanner", () => {
|
|
6
|
+
it("renders nothing when catch-up mode is inactive", () => {
|
|
7
|
+
const catchUp: CatchUpState = {
|
|
8
|
+
active: false,
|
|
9
|
+
bufferedCount: 0,
|
|
10
|
+
flush: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const { container } = render(<CatchUpBanner catchUp={catchUp} />);
|
|
14
|
+
expect(container.firstChild).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders the banner when catch-up mode is active", () => {
|
|
18
|
+
const catchUp: CatchUpState = {
|
|
19
|
+
active: true,
|
|
20
|
+
bufferedCount: 12,
|
|
21
|
+
flush: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
render(<CatchUpBanner catchUp={catchUp} />);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByTestId("catch-up-banner")).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText("12")).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText(/runs updated while you were away/)).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shows the refresh button", () => {
|
|
32
|
+
const catchUp: CatchUpState = {
|
|
33
|
+
active: true,
|
|
34
|
+
bufferedCount: 5,
|
|
35
|
+
flush: vi.fn(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
render(<CatchUpBanner catchUp={catchUp} />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("catch-up-refresh-btn")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText("Refresh now")).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("calls flush when refresh button is clicked", async () => {
|
|
45
|
+
const flush = vi.fn();
|
|
46
|
+
const catchUp: CatchUpState = {
|
|
47
|
+
active: true,
|
|
48
|
+
bufferedCount: 8,
|
|
49
|
+
flush,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
render(<CatchUpBanner catchUp={catchUp} />);
|
|
53
|
+
|
|
54
|
+
const button = screen.getByTestId("catch-up-refresh-btn");
|
|
55
|
+
button.click();
|
|
56
|
+
|
|
57
|
+
expect(flush).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("displays the correct buffered count", () => {
|
|
61
|
+
const catchUp: CatchUpState = {
|
|
62
|
+
active: true,
|
|
63
|
+
bufferedCount: 42,
|
|
64
|
+
flush: vi.fn(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
render(<CatchUpBanner catchUp={catchUp} />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText("42")).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows summary context when summary prop is provided", () => {
|
|
73
|
+
const catchUp: CatchUpState = {
|
|
74
|
+
active: true,
|
|
75
|
+
bufferedCount: 15,
|
|
76
|
+
flush: vi.fn(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
render(
|
|
80
|
+
<CatchUpBanner
|
|
81
|
+
catchUp={catchUp}
|
|
82
|
+
summary={{ failedRuns: 2, completedRuns: 10, pendingBreakpoints: 1 }}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const summaryEl = screen.getByTestId("catch-up-summary");
|
|
87
|
+
expect(summaryEl).toBeInTheDocument();
|
|
88
|
+
expect(summaryEl).toHaveTextContent("2 failed");
|
|
89
|
+
expect(summaryEl).toHaveTextContent("1 awaiting input");
|
|
90
|
+
expect(summaryEl).toHaveTextContent("10 completed");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("omits summary line when all summary counts are zero", () => {
|
|
94
|
+
const catchUp: CatchUpState = {
|
|
95
|
+
active: true,
|
|
96
|
+
bufferedCount: 5,
|
|
97
|
+
flush: vi.fn(),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
render(
|
|
101
|
+
<CatchUpBanner
|
|
102
|
+
catchUp={catchUp}
|
|
103
|
+
summary={{ failedRuns: 0, completedRuns: 0, pendingBreakpoints: 0 }}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(screen.queryByTestId("catch-up-summary")).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits summary line when summary prop is not provided", () => {
|
|
111
|
+
const catchUp: CatchUpState = {
|
|
112
|
+
active: true,
|
|
113
|
+
bufferedCount: 5,
|
|
114
|
+
flush: vi.fn(),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
render(<CatchUpBanner catchUp={catchUp} />);
|
|
118
|
+
|
|
119
|
+
expect(screen.queryByTestId("catch-up-summary")).not.toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("shows only failed runs in summary when others are zero", () => {
|
|
123
|
+
const catchUp: CatchUpState = {
|
|
124
|
+
active: true,
|
|
125
|
+
bufferedCount: 3,
|
|
126
|
+
flush: vi.fn(),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<CatchUpBanner
|
|
131
|
+
catchUp={catchUp}
|
|
132
|
+
summary={{ failedRuns: 4, completedRuns: 0, pendingBreakpoints: 0 }}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const summaryEl = screen.getByTestId("catch-up-summary");
|
|
137
|
+
expect(summaryEl).toHaveTextContent("4 failed");
|
|
138
|
+
expect(summaryEl).not.toHaveTextContent("completed");
|
|
139
|
+
expect(summaryEl).not.toHaveTextContent("awaiting");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { ExecutiveSummaryBanner, type ExecutiveSummaryMetrics } from '../executive-summary-banner';
|
|
5
|
+
|
|
6
|
+
function makeMetrics(overrides: Partial<ExecutiveSummaryMetrics> = {}): ExecutiveSummaryMetrics {
|
|
7
|
+
return {
|
|
8
|
+
totalProjects: 5,
|
|
9
|
+
activeRuns: 0,
|
|
10
|
+
failedRuns: 0,
|
|
11
|
+
completedRuns: 10,
|
|
12
|
+
staleRuns: 0,
|
|
13
|
+
pendingBreakpoints: 0,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('ExecutiveSummaryBanner', () => {
|
|
19
|
+
it('renders with role="status" for accessibility', () => {
|
|
20
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
|
|
21
|
+
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders data-testid for querying', () => {
|
|
25
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
|
|
26
|
+
expect(screen.getByTestId('executive-summary-banner')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// --- Healthy state ---
|
|
30
|
+
it('shows all-healthy message when no issues exist', () => {
|
|
31
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
|
|
32
|
+
expect(screen.getByText('All 5 projects healthy')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('includes active run count in healthy message when runs are active', () => {
|
|
36
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ activeRuns: 3 })} />);
|
|
37
|
+
expect(screen.getByText(/All 5 projects healthy/)).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText(/3 runs in progress/)).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('uses singular "project" for single project', () => {
|
|
42
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ totalProjects: 1 })} />);
|
|
43
|
+
expect(screen.getByText('All 1 project healthy')).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses singular "run" for single active run', () => {
|
|
47
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ activeRuns: 1 })} />);
|
|
48
|
+
expect(screen.getByText(/1 run in progress/)).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- Failure state (red) ---
|
|
52
|
+
it('shows failure message when runs are failing', () => {
|
|
53
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 2 })} />);
|
|
54
|
+
expect(screen.getByText(/2 runs failing/)).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses singular "run" for single failure', () => {
|
|
58
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />);
|
|
59
|
+
expect(screen.getByText(/1 run failing/)).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('applies error styling for failures', () => {
|
|
63
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />);
|
|
64
|
+
const banner = screen.getByTestId('executive-summary-banner');
|
|
65
|
+
expect(banner.className).toMatch(/border-error/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// --- Amber state (pending approvals) ---
|
|
69
|
+
it('shows pending approval message', () => {
|
|
70
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 2 })} />);
|
|
71
|
+
expect(screen.getByText(/2 approvals need your attention/)).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('uses singular for single approval', () => {
|
|
75
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 1 })} />);
|
|
76
|
+
expect(screen.getByText(/1 approval needs your attention/)).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('applies warning styling for pending approvals', () => {
|
|
80
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 1 })} />);
|
|
81
|
+
const banner = screen.getByTestId('executive-summary-banner');
|
|
82
|
+
expect(banner.className).toMatch(/border-warning/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- Stale state (amber) ---
|
|
86
|
+
it('shows stale run message', () => {
|
|
87
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics({ staleRuns: 3 })} />);
|
|
88
|
+
expect(screen.getByText(/3 stale runs/)).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// --- Combined states ---
|
|
92
|
+
it('combines failures and approvals', () => {
|
|
93
|
+
render(
|
|
94
|
+
<ExecutiveSummaryBanner
|
|
95
|
+
metrics={makeMetrics({ failedRuns: 1, pendingBreakpoints: 2 })}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByText(/1 run failing/)).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText(/2 approvals need your attention/)).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('applies error styling when both failures and approvals exist', () => {
|
|
103
|
+
render(
|
|
104
|
+
<ExecutiveSummaryBanner
|
|
105
|
+
metrics={makeMetrics({ failedRuns: 1, pendingBreakpoints: 2 })}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
const banner = screen.getByTestId('executive-summary-banner');
|
|
109
|
+
expect(banner.className).toMatch(/border-error/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('applies success styling when all healthy', () => {
|
|
113
|
+
render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
|
|
114
|
+
const banner = screen.getByTestId('executive-summary-banner');
|
|
115
|
+
expect(banner.className).toMatch(/border-success/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- Dismissed state ---
|
|
119
|
+
it('returns null when dismissed is true', () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} dismissed={true} />
|
|
122
|
+
);
|
|
123
|
+
expect(container.innerHTML).toBe('');
|
|
124
|
+
expect(screen.queryByTestId('executive-summary-banner')).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders normally when dismissed is false', () => {
|
|
128
|
+
render(
|
|
129
|
+
<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} dismissed={false} />
|
|
130
|
+
);
|
|
131
|
+
expect(screen.getByTestId('executive-summary-banner')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// --- onDismiss callback ---
|
|
135
|
+
it('fires onDismiss callback when X button is clicked', async () => {
|
|
136
|
+
const user = userEvent.setup();
|
|
137
|
+
const onDismiss = vi.fn();
|
|
138
|
+
render(
|
|
139
|
+
<ExecutiveSummaryBanner
|
|
140
|
+
metrics={makeMetrics({ failedRuns: 1 })}
|
|
141
|
+
onDismiss={onDismiss}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const dismissBtn = screen.getByTestId('executive-summary-dismiss');
|
|
146
|
+
await user.click(dismissBtn);
|
|
147
|
+
|
|
148
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not show dismiss button when onDismiss is not provided', () => {
|
|
152
|
+
render(
|
|
153
|
+
<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />
|
|
154
|
+
);
|
|
155
|
+
expect(screen.queryByTestId('executive-summary-dismiss')).not.toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not show dismiss button in healthy state even if onDismiss is provided', () => {
|
|
159
|
+
render(
|
|
160
|
+
<ExecutiveSummaryBanner metrics={makeMetrics()} onDismiss={vi.fn()} />
|
|
161
|
+
);
|
|
162
|
+
expect(screen.queryByTestId('executive-summary-dismiss')).not.toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { KpiGrid } from '../kpi-grid';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import type { DashboardMetrics } from '@/hooks/use-run-dashboard';
|
|
6
|
+
|
|
7
|
+
const baseMetrics: DashboardMetrics = {
|
|
8
|
+
totalRuns: 20,
|
|
9
|
+
activeRuns: 5,
|
|
10
|
+
completedRuns: 12,
|
|
11
|
+
failedRuns: 3,
|
|
12
|
+
staleRuns: 0,
|
|
13
|
+
totalTasks: 100,
|
|
14
|
+
completedTasks: 80,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('KpiGrid', () => {
|
|
18
|
+
it('renders all four metric tiles without stale', () => {
|
|
19
|
+
render(
|
|
20
|
+
<KpiGrid
|
|
21
|
+
metrics={baseMetrics}
|
|
22
|
+
statusFilter="all"
|
|
23
|
+
hasStaleRuns={false}
|
|
24
|
+
onToggleFilter={vi.fn()}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByTestId('metric-tile-total-runs')).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByTestId('metric-tile-active')).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByTestId('metric-tile-completed')).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByTestId('metric-tile-failed')).toBeInTheDocument();
|
|
31
|
+
expect(screen.queryByTestId('metric-tile-stale')).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders stale tile when hasStaleRuns is true', () => {
|
|
35
|
+
render(
|
|
36
|
+
<KpiGrid
|
|
37
|
+
metrics={{ ...baseMetrics, staleRuns: 2 }}
|
|
38
|
+
statusFilter="all"
|
|
39
|
+
hasStaleRuns={true}
|
|
40
|
+
onToggleFilter={vi.fn()}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByTestId('metric-tile-stale')).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('displays correct metric values', () => {
|
|
47
|
+
render(
|
|
48
|
+
<KpiGrid
|
|
49
|
+
metrics={baseMetrics}
|
|
50
|
+
statusFilter="all"
|
|
51
|
+
hasStaleRuns={false}
|
|
52
|
+
onToggleFilter={vi.fn()}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByTestId('metric-tile-total-runs')).toHaveTextContent('20');
|
|
56
|
+
expect(screen.getByTestId('metric-tile-active')).toHaveTextContent('5');
|
|
57
|
+
expect(screen.getByTestId('metric-tile-completed')).toHaveTextContent('12');
|
|
58
|
+
expect(screen.getByTestId('metric-tile-failed')).toHaveTextContent('3');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('calls onToggleFilter when a tile is clicked', async () => {
|
|
62
|
+
const onToggle = vi.fn();
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
render(
|
|
65
|
+
<KpiGrid
|
|
66
|
+
metrics={baseMetrics}
|
|
67
|
+
statusFilter="all"
|
|
68
|
+
hasStaleRuns={false}
|
|
69
|
+
onToggleFilter={onToggle}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await user.click(screen.getByTestId('metric-tile-failed'));
|
|
74
|
+
expect(onToggle).toHaveBeenCalledWith('failed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('marks active tile with aria-pressed true', () => {
|
|
78
|
+
render(
|
|
79
|
+
<KpiGrid
|
|
80
|
+
metrics={baseMetrics}
|
|
81
|
+
statusFilter="waiting"
|
|
82
|
+
hasStaleRuns={false}
|
|
83
|
+
onToggleFilter={vi.fn()}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByTestId('metric-tile-active')).toHaveAttribute('aria-pressed', 'true');
|
|
87
|
+
expect(screen.getByTestId('metric-tile-total-runs')).toHaveAttribute('aria-pressed', 'false');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders the kpi-grid container with aria-label', () => {
|
|
91
|
+
render(
|
|
92
|
+
<KpiGrid
|
|
93
|
+
metrics={baseMetrics}
|
|
94
|
+
statusFilter="all"
|
|
95
|
+
hasStaleRuns={false}
|
|
96
|
+
onToggleFilter={vi.fn()}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByTestId('kpi-grid')).toHaveAttribute('aria-label', 'Key metrics');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { PaginationControls } from '../pagination-controls';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
|
|
6
|
+
describe('PaginationControls', () => {
|
|
7
|
+
const defaultProps = {
|
|
8
|
+
currentPage: 0,
|
|
9
|
+
totalItems: 25,
|
|
10
|
+
itemsPerPage: 10,
|
|
11
|
+
onPageChange: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders nothing when totalItems is 0', () => {
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<PaginationControls {...defaultProps} totalItems={0} />
|
|
21
|
+
);
|
|
22
|
+
expect(container.firstChild).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('displays the item range text', () => {
|
|
26
|
+
render(<PaginationControls {...defaultProps} currentPage={0} />);
|
|
27
|
+
// Rendered as "1-10 of 25" (with en-dash or hyphen)
|
|
28
|
+
// The range text span contains startItem, endItem, and totalItems
|
|
29
|
+
const rangeText = screen.getByText(/of 25/);
|
|
30
|
+
expect(rangeText).toBeInTheDocument();
|
|
31
|
+
expect(rangeText).toHaveTextContent('1');
|
|
32
|
+
expect(rangeText).toHaveTextContent('10');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('displays correct range on second page', () => {
|
|
36
|
+
render(<PaginationControls {...defaultProps} currentPage={1} />);
|
|
37
|
+
expect(screen.getByText(/11/)).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('caps the end item at totalItems on the last page', () => {
|
|
41
|
+
render(
|
|
42
|
+
<PaginationControls {...defaultProps} currentPage={2} totalItems={25} itemsPerPage={10} />
|
|
43
|
+
);
|
|
44
|
+
// Page 3 (index 2): items 21-25
|
|
45
|
+
expect(screen.getByText(/25/)).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('displays the current page number (1-indexed)', () => {
|
|
49
|
+
render(<PaginationControls {...defaultProps} currentPage={1} />);
|
|
50
|
+
// Should show "2" as the current page indicator
|
|
51
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('disables the previous button on the first page', () => {
|
|
55
|
+
render(<PaginationControls {...defaultProps} currentPage={0} />);
|
|
56
|
+
const prevBtn = screen.getByLabelText('Previous page');
|
|
57
|
+
expect(prevBtn).toBeDisabled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('enables the previous button when not on the first page', () => {
|
|
61
|
+
render(<PaginationControls {...defaultProps} currentPage={1} />);
|
|
62
|
+
const prevBtn = screen.getByLabelText('Previous page');
|
|
63
|
+
expect(prevBtn).not.toBeDisabled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('disables the next button on the last page', () => {
|
|
67
|
+
// totalItems=25, itemsPerPage=10 => 3 pages (0,1,2), last page is index 2
|
|
68
|
+
render(<PaginationControls {...defaultProps} currentPage={2} />);
|
|
69
|
+
const nextBtn = screen.getByLabelText('Next page');
|
|
70
|
+
expect(nextBtn).toBeDisabled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('enables the next button when not on the last page', () => {
|
|
74
|
+
render(<PaginationControls {...defaultProps} currentPage={0} />);
|
|
75
|
+
const nextBtn = screen.getByLabelText('Next page');
|
|
76
|
+
expect(nextBtn).not.toBeDisabled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('calls onPageChange with previous page when clicking prev', async () => {
|
|
80
|
+
const onPageChange = vi.fn();
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
render(
|
|
83
|
+
<PaginationControls {...defaultProps} currentPage={1} onPageChange={onPageChange} />
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await user.click(screen.getByLabelText('Previous page'));
|
|
87
|
+
expect(onPageChange).toHaveBeenCalledWith(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('calls onPageChange with next page when clicking next', async () => {
|
|
91
|
+
const onPageChange = vi.fn();
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
render(
|
|
94
|
+
<PaginationControls {...defaultProps} currentPage={0} onPageChange={onPageChange} />
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await user.click(screen.getByLabelText('Next page'));
|
|
98
|
+
expect(onPageChange).toHaveBeenCalledWith(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('handles single page of items (both buttons disabled)', () => {
|
|
102
|
+
render(
|
|
103
|
+
<PaginationControls
|
|
104
|
+
{...defaultProps}
|
|
105
|
+
currentPage={0}
|
|
106
|
+
totalItems={5}
|
|
107
|
+
itemsPerPage={10}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
expect(screen.getByLabelText('Previous page')).toBeDisabled();
|
|
111
|
+
expect(screen.getByLabelText('Next page')).toBeDisabled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('shows page 1 for a single page', () => {
|
|
115
|
+
render(
|
|
116
|
+
<PaginationControls
|
|
117
|
+
{...defaultProps}
|
|
118
|
+
currentPage={0}
|
|
119
|
+
totalItems={5}
|
|
120
|
+
itemsPerPage={10}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { ProjectAccordion } from '../project-accordion';
|
|
4
|
+
import { createMockProjectSummary, resetIdCounter } from '@/test/fixtures';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
|
|
7
|
+
// Mock ProjectSection to avoid hook side effects (useProjectRuns calls useSmartPolling)
|
|
8
|
+
vi.mock('../project-section', () => ({
|
|
9
|
+
ProjectSection: ({ projectName, enabled }: { projectName: string; enabled?: boolean }) => (
|
|
10
|
+
<div data-testid={`project-section-${projectName}`} data-enabled={String(enabled ?? true)}>
|
|
11
|
+
Section: {projectName}
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock ProjectSectionHeader to simplify testing
|
|
17
|
+
vi.mock('../project-section-header', () => ({
|
|
18
|
+
ProjectSectionHeader: ({
|
|
19
|
+
projectName,
|
|
20
|
+
activeRuns,
|
|
21
|
+
totalRuns,
|
|
22
|
+
}: {
|
|
23
|
+
projectName: string;
|
|
24
|
+
activeRuns: number;
|
|
25
|
+
totalRuns: number;
|
|
26
|
+
}) => (
|
|
27
|
+
<div data-testid={`header-${projectName}`}>
|
|
28
|
+
{projectName} ({activeRuns} active, {totalRuns} total)
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
resetIdCounter();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('ProjectAccordion', () => {
|
|
38
|
+
it('renders "No projects found" when projects list is empty', () => {
|
|
39
|
+
render(<ProjectAccordion projects={[]} />);
|
|
40
|
+
expect(screen.getByText('No projects found')).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders project section headers for each project', () => {
|
|
44
|
+
const projects = [
|
|
45
|
+
createMockProjectSummary({ projectName: 'project-a', activeRuns: 1 }),
|
|
46
|
+
createMockProjectSummary({ projectName: 'project-b', activeRuns: 0 }),
|
|
47
|
+
];
|
|
48
|
+
render(<ProjectAccordion projects={projects} />);
|
|
49
|
+
expect(screen.getByTestId('header-project-a')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByTestId('header-project-b')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders header text with active runs and total runs', () => {
|
|
54
|
+
const projects = [
|
|
55
|
+
createMockProjectSummary({ projectName: 'alpha', activeRuns: 3, totalRuns: 10 }),
|
|
56
|
+
];
|
|
57
|
+
render(<ProjectAccordion projects={projects} />);
|
|
58
|
+
expect(screen.getByText('alpha (3 active, 10 total)')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('auto-expands projects with active runs by default', () => {
|
|
62
|
+
const projects = [
|
|
63
|
+
createMockProjectSummary({ projectName: 'active-project', activeRuns: 2 }),
|
|
64
|
+
createMockProjectSummary({ projectName: 'idle-project', activeRuns: 0 }),
|
|
65
|
+
];
|
|
66
|
+
render(<ProjectAccordion projects={projects} />);
|
|
67
|
+
// The active project section should be visible since it's expanded
|
|
68
|
+
expect(screen.getByTestId('project-section-active-project')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('can expand a collapsed project by clicking its trigger', async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
const projects = [
|
|
74
|
+
createMockProjectSummary({ projectName: 'idle-project', activeRuns: 0 }),
|
|
75
|
+
];
|
|
76
|
+
render(<ProjectAccordion projects={projects} />);
|
|
77
|
+
|
|
78
|
+
// Click the trigger to expand
|
|
79
|
+
const trigger = screen.getByTestId('header-idle-project');
|
|
80
|
+
await user.click(trigger);
|
|
81
|
+
|
|
82
|
+
// Now the section should become visible
|
|
83
|
+
expect(screen.getByTestId('project-section-idle-project')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders multiple projects', () => {
|
|
87
|
+
const projects = [
|
|
88
|
+
createMockProjectSummary({ projectName: 'project-1' }),
|
|
89
|
+
createMockProjectSummary({ projectName: 'project-2' }),
|
|
90
|
+
createMockProjectSummary({ projectName: 'project-3' }),
|
|
91
|
+
];
|
|
92
|
+
render(<ProjectAccordion projects={projects} />);
|
|
93
|
+
expect(screen.getByTestId('header-project-1')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByTestId('header-project-2')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByTestId('header-project-3')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
});
|