@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,109 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { RunFilterBar } from '../run-filter-bar';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import type { DashboardStatusFilter } from '@/hooks/use-run-dashboard';
|
|
6
|
+
|
|
7
|
+
const defaultFilterCounts: Record<DashboardStatusFilter, number> = {
|
|
8
|
+
all: 20,
|
|
9
|
+
waiting: 5,
|
|
10
|
+
stale: 0,
|
|
11
|
+
completed: 12,
|
|
12
|
+
failed: 3,
|
|
13
|
+
pending: 0,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('RunFilterBar', () => {
|
|
17
|
+
const defaultProps = {
|
|
18
|
+
statusFilter: 'all' as DashboardStatusFilter,
|
|
19
|
+
onStatusFilterChange: vi.fn(),
|
|
20
|
+
filterCounts: defaultFilterCounts,
|
|
21
|
+
sortMode: 'status' as const,
|
|
22
|
+
onSortModeToggle: vi.fn(),
|
|
23
|
+
filteredProjectCount: 5,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders filter pills for all, running, completed, failed', () => {
|
|
31
|
+
render(<RunFilterBar {...defaultProps} />);
|
|
32
|
+
expect(screen.getByTestId('filter-pill-all')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByTestId('filter-pill-waiting')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByTestId('filter-pill-completed')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByTestId('filter-pill-failed')).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('hides stale pill when count is 0', () => {
|
|
39
|
+
render(<RunFilterBar {...defaultProps} />);
|
|
40
|
+
expect(screen.queryByTestId('filter-pill-stale')).not.toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('shows stale pill when count > 0', () => {
|
|
44
|
+
render(
|
|
45
|
+
<RunFilterBar
|
|
46
|
+
{...defaultProps}
|
|
47
|
+
filterCounts={{ ...defaultFilterCounts, stale: 2 }}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
expect(screen.getByTestId('filter-pill-stale')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calls onStatusFilterChange when a pill is clicked', async () => {
|
|
54
|
+
const onStatusFilterChange = vi.fn();
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
render(
|
|
57
|
+
<RunFilterBar {...defaultProps} onStatusFilterChange={onStatusFilterChange} />
|
|
58
|
+
);
|
|
59
|
+
await user.click(screen.getByTestId('filter-pill-failed'));
|
|
60
|
+
expect(onStatusFilterChange).toHaveBeenCalledWith('failed');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('marks active filter pill with aria-pressed', () => {
|
|
64
|
+
render(<RunFilterBar {...defaultProps} statusFilter="completed" />);
|
|
65
|
+
expect(screen.getByTestId('filter-pill-completed')).toHaveAttribute('aria-pressed', 'true');
|
|
66
|
+
expect(screen.getByTestId('filter-pill-all')).toHaveAttribute('aria-pressed', 'false');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders the sort toggle button', () => {
|
|
70
|
+
render(<RunFilterBar {...defaultProps} />);
|
|
71
|
+
expect(screen.getByTestId('sort-toggle')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('displays "By Status" when sortMode is status', () => {
|
|
75
|
+
render(<RunFilterBar {...defaultProps} sortMode="status" />);
|
|
76
|
+
expect(screen.getByTestId('sort-toggle')).toHaveTextContent('By Status');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('displays "By Activity" when sortMode is activity', () => {
|
|
80
|
+
render(<RunFilterBar {...defaultProps} sortMode="activity" />);
|
|
81
|
+
expect(screen.getByTestId('sort-toggle')).toHaveTextContent('By Activity');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('calls onSortModeToggle when sort button is clicked', async () => {
|
|
85
|
+
const onSortModeToggle = vi.fn();
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
render(<RunFilterBar {...defaultProps} onSortModeToggle={onSortModeToggle} />);
|
|
88
|
+
await user.click(screen.getByTestId('sort-toggle'));
|
|
89
|
+
expect(onSortModeToggle).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('displays the project count', () => {
|
|
93
|
+
render(<RunFilterBar {...defaultProps} filteredProjectCount={7} />);
|
|
94
|
+
expect(screen.getByTestId('project-count')).toHaveTextContent('7 projects');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('uses singular "project" when count is 1', () => {
|
|
98
|
+
render(<RunFilterBar {...defaultProps} filteredProjectCount={1} />);
|
|
99
|
+
expect(screen.getByTestId('project-count')).toHaveTextContent('1 project');
|
|
100
|
+
expect(screen.getByTestId('project-count')).not.toHaveTextContent('projects');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('displays filter counts in badges', () => {
|
|
104
|
+
render(<RunFilterBar {...defaultProps} />);
|
|
105
|
+
// The "All" pill should show count of 20
|
|
106
|
+
expect(screen.getByTestId('filter-pill-all')).toHaveTextContent('20');
|
|
107
|
+
expect(screen.getByTestId('filter-pill-failed')).toHaveTextContent('3');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { RunList } from '../run-list';
|
|
4
|
+
import { createMockRun, resetIdCounter } from '@/test/fixtures';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
|
|
7
|
+
// Mock next/link
|
|
8
|
+
vi.mock('next/link', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
11
|
+
<a href={href} data-testid="next-link">
|
|
12
|
+
{children}
|
|
13
|
+
</a>
|
|
14
|
+
),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock ProjectSection to avoid hook side effects
|
|
18
|
+
vi.mock('../project-section', () => ({
|
|
19
|
+
ProjectSection: ({ projectName }: { projectName: string }) => (
|
|
20
|
+
<div data-testid={`project-section-${projectName}`}>{projectName}</div>
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetIdCounter();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('RunList', () => {
|
|
29
|
+
it('renders an empty state when there are no runs', () => {
|
|
30
|
+
render(<RunList runs={[]} />);
|
|
31
|
+
expect(screen.getByText('No runs found')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a list of RunCards for each run', () => {
|
|
35
|
+
const runs = [
|
|
36
|
+
createMockRun({ runId: 'run-1', processId: 'process-alpha' }),
|
|
37
|
+
createMockRun({ runId: 'run-2', processId: 'process-beta' }),
|
|
38
|
+
];
|
|
39
|
+
render(<RunList runs={runs} />);
|
|
40
|
+
expect(screen.getByText('Process Alpha')).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText('Process Beta')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('marks the selected run card', () => {
|
|
45
|
+
const runs = [
|
|
46
|
+
createMockRun({ runId: 'run-1' }),
|
|
47
|
+
createMockRun({ runId: 'run-2' }),
|
|
48
|
+
];
|
|
49
|
+
const { container } = render(<RunList runs={runs} selectedIndex={0} />);
|
|
50
|
+
// The first card should have a ring-1 class (selected)
|
|
51
|
+
const cards = container.querySelectorAll('.ring-1');
|
|
52
|
+
expect(cards.length).toBeGreaterThanOrEqual(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('paginates runs and shows "Show more" button', () => {
|
|
56
|
+
// Create 12 runs (default page size is 10)
|
|
57
|
+
const runs = Array.from({ length: 12 }, (_, i) =>
|
|
58
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
59
|
+
);
|
|
60
|
+
render(<RunList runs={runs} />);
|
|
61
|
+
// First page shows 10 items
|
|
62
|
+
const links = screen.getAllByTestId('next-link');
|
|
63
|
+
expect(links).toHaveLength(10);
|
|
64
|
+
// "Show more" button exists with remaining count
|
|
65
|
+
expect(screen.getByText(/Show more \(2 remaining\)/)).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('loads more runs when "Show more" is clicked', async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
const runs = Array.from({ length: 12 }, (_, i) =>
|
|
71
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
72
|
+
);
|
|
73
|
+
render(<RunList runs={runs} />);
|
|
74
|
+
|
|
75
|
+
const showMoreBtn = screen.getByText(/Show more/);
|
|
76
|
+
await user.click(showMoreBtn);
|
|
77
|
+
|
|
78
|
+
// After clicking, all 12 runs should be visible
|
|
79
|
+
const links = screen.getAllByTestId('next-link');
|
|
80
|
+
expect(links).toHaveLength(12);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not show "Show more" when all runs fit on one page', () => {
|
|
84
|
+
const runs = Array.from({ length: 5 }, (_, i) =>
|
|
85
|
+
createMockRun({ runId: `run-${i}` })
|
|
86
|
+
);
|
|
87
|
+
render(<RunList runs={runs} />);
|
|
88
|
+
expect(screen.queryByText(/Show more/)).not.toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('groupByProject mode', () => {
|
|
92
|
+
it('renders ProjectSection components grouped by project name', () => {
|
|
93
|
+
const runs = [
|
|
94
|
+
createMockRun({ runId: 'run-1', projectName: 'alpha-project' }),
|
|
95
|
+
createMockRun({ runId: 'run-2', projectName: 'beta-project' }),
|
|
96
|
+
createMockRun({ runId: 'run-3', projectName: 'alpha-project' }),
|
|
97
|
+
];
|
|
98
|
+
render(<RunList runs={runs} groupByProject />);
|
|
99
|
+
expect(screen.getByTestId('project-section-alpha-project')).toBeInTheDocument();
|
|
100
|
+
expect(screen.getByTestId('project-section-beta-project')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('groups runs without a project under "Unknown Project"', () => {
|
|
104
|
+
// createMockRun defaults projectName to 'my-project', so we must explicitly set it empty
|
|
105
|
+
const run = createMockRun({ runId: 'run-1' });
|
|
106
|
+
// Override projectName to undefined after creation
|
|
107
|
+
(run as any).projectName = undefined;
|
|
108
|
+
render(<RunList runs={[run]} groupByProject />);
|
|
109
|
+
expect(screen.getByTestId('project-section-Unknown Project')).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('sorts project groups alphabetically', () => {
|
|
113
|
+
const runs = [
|
|
114
|
+
createMockRun({ runId: 'run-1', projectName: 'zebra' }),
|
|
115
|
+
createMockRun({ runId: 'run-2', projectName: 'alpha' }),
|
|
116
|
+
];
|
|
117
|
+
render(<RunList runs={runs} groupByProject />);
|
|
118
|
+
const sections = screen.getAllByTestId(/project-section-/);
|
|
119
|
+
expect(sections[0]).toHaveTextContent('alpha');
|
|
120
|
+
expect(sections[1]).toHaveTextContent('zebra');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { SearchFilter } from '../search-filter';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
|
|
6
|
+
describe('SearchFilter', () => {
|
|
7
|
+
const defaultProps = {
|
|
8
|
+
search: '',
|
|
9
|
+
onSearchChange: vi.fn(),
|
|
10
|
+
statusFilter: 'all' as const,
|
|
11
|
+
onStatusFilterChange: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders the search input with placeholder', () => {
|
|
19
|
+
render(<SearchFilter {...defaultProps} />);
|
|
20
|
+
expect(screen.getByPlaceholderText('Search runs...')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('displays the current search value', () => {
|
|
24
|
+
render(<SearchFilter {...defaultProps} search="my query" />);
|
|
25
|
+
expect(screen.getByPlaceholderText('Search runs...')).toHaveValue('my query');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('calls onSearchChange when typing in the search input', async () => {
|
|
29
|
+
const onSearchChange = vi.fn();
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(<SearchFilter {...defaultProps} onSearchChange={onSearchChange} />);
|
|
32
|
+
|
|
33
|
+
const input = screen.getByPlaceholderText('Search runs...');
|
|
34
|
+
await user.type(input, 'test');
|
|
35
|
+
|
|
36
|
+
// Called once per character; the component receives change events with cumulative value
|
|
37
|
+
// but because it's a controlled input with value from props (empty string),
|
|
38
|
+
// each keystroke triggers onChange with just that character appended to the controlled value.
|
|
39
|
+
// Since search prop stays '' (controlled), each call receives just the new char.
|
|
40
|
+
expect(onSearchChange).toHaveBeenCalledTimes(4);
|
|
41
|
+
// Verify it was called with each character
|
|
42
|
+
expect(onSearchChange).toHaveBeenNthCalledWith(1, 't');
|
|
43
|
+
expect(onSearchChange).toHaveBeenNthCalledWith(2, 'e');
|
|
44
|
+
expect(onSearchChange).toHaveBeenNthCalledWith(3, 's');
|
|
45
|
+
expect(onSearchChange).toHaveBeenNthCalledWith(4, 't');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders all status filter buttons', () => {
|
|
49
|
+
render(<SearchFilter {...defaultProps} />);
|
|
50
|
+
expect(screen.getByText('All')).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText('Running')).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText('Completed')).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByText('Failed')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('highlights the active status filter', () => {
|
|
57
|
+
render(<SearchFilter {...defaultProps} statusFilter="completed" />);
|
|
58
|
+
const completedBtn = screen.getByText('Completed');
|
|
59
|
+
// Active filter has bg-primary/15 class
|
|
60
|
+
expect(completedBtn.className).toContain('text-primary');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('calls onStatusFilterChange when clicking a filter button', async () => {
|
|
64
|
+
const onStatusFilterChange = vi.fn();
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(
|
|
67
|
+
<SearchFilter
|
|
68
|
+
{...defaultProps}
|
|
69
|
+
onStatusFilterChange={onStatusFilterChange}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await user.click(screen.getByText('Failed'));
|
|
74
|
+
expect(onStatusFilterChange).toHaveBeenCalledWith('failed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('calls onStatusFilterChange with "all" when clicking All button', async () => {
|
|
78
|
+
const onStatusFilterChange = vi.fn();
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
render(
|
|
81
|
+
<SearchFilter
|
|
82
|
+
{...defaultProps}
|
|
83
|
+
statusFilter="completed"
|
|
84
|
+
onStatusFilterChange={onStatusFilterChange}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await user.click(screen.getByText('All'));
|
|
89
|
+
expect(onStatusFilterChange).toHaveBeenCalledWith('all');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('calls onStatusFilterChange with "waiting" when clicking Running', async () => {
|
|
93
|
+
const onStatusFilterChange = vi.fn();
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
render(
|
|
96
|
+
<SearchFilter
|
|
97
|
+
{...defaultProps}
|
|
98
|
+
onStatusFilterChange={onStatusFilterChange}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await user.click(screen.getByText('Running'));
|
|
103
|
+
expect(onStatusFilterChange).toHaveBeenCalledWith('waiting');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not show Group button when onGroupByProjectChange is not provided', () => {
|
|
107
|
+
render(<SearchFilter {...defaultProps} />);
|
|
108
|
+
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('shows Group button when onGroupByProjectChange is provided', () => {
|
|
112
|
+
render(
|
|
113
|
+
<SearchFilter
|
|
114
|
+
{...defaultProps}
|
|
115
|
+
onGroupByProjectChange={vi.fn()}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
expect(screen.getByText('Group')).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calls onGroupByProjectChange with toggled value when Group is clicked', async () => {
|
|
122
|
+
const onGroupByProjectChange = vi.fn();
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
render(
|
|
125
|
+
<SearchFilter
|
|
126
|
+
{...defaultProps}
|
|
127
|
+
groupByProject={false}
|
|
128
|
+
onGroupByProjectChange={onGroupByProjectChange}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await user.click(screen.getByText('Group'));
|
|
133
|
+
expect(onGroupByProjectChange).toHaveBeenCalledWith(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('toggles Group button off when groupByProject is true', async () => {
|
|
137
|
+
const onGroupByProjectChange = vi.fn();
|
|
138
|
+
const user = userEvent.setup();
|
|
139
|
+
render(
|
|
140
|
+
<SearchFilter
|
|
141
|
+
{...defaultProps}
|
|
142
|
+
groupByProject={true}
|
|
143
|
+
onGroupByProjectChange={onGroupByProjectChange}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await user.click(screen.getByText('Group'));
|
|
148
|
+
expect(onGroupByProjectChange).toHaveBeenCalledWith(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { VirtualizedRunList } from '../virtualized-run-list';
|
|
4
|
+
import { RunCard } from '../run-card';
|
|
5
|
+
import { createMockRun, resetIdCounter } from '@/test/fixtures';
|
|
6
|
+
import type { Run } from '@/types';
|
|
7
|
+
|
|
8
|
+
// Mock next/link to render a plain anchor
|
|
9
|
+
vi.mock('next/link', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
12
|
+
<a href={href} data-testid="next-link">
|
|
13
|
+
{children}
|
|
14
|
+
</a>
|
|
15
|
+
),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetIdCounter();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('VirtualizedRunList', () => {
|
|
23
|
+
describe('flat rendering (below virtualization threshold)', () => {
|
|
24
|
+
it('renders all run cards for a small list', () => {
|
|
25
|
+
const runs = Array.from({ length: 5 }, (_, i) =>
|
|
26
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
27
|
+
);
|
|
28
|
+
render(<VirtualizedRunList runs={runs} />);
|
|
29
|
+
expect(screen.getByTestId('run-list-flat')).toBeInTheDocument();
|
|
30
|
+
const links = screen.getAllByTestId('next-link');
|
|
31
|
+
expect(links).toHaveLength(5);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders empty list without crashing', () => {
|
|
35
|
+
const { container } = render(<VirtualizedRunList runs={[]} />);
|
|
36
|
+
expect(container.querySelector('[data-testid="run-list-flat"]')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses runId as key for stable ordering', () => {
|
|
40
|
+
const runs = [
|
|
41
|
+
createMockRun({ runId: 'run-b', processId: 'beta' }),
|
|
42
|
+
createMockRun({ runId: 'run-a', processId: 'alpha' }),
|
|
43
|
+
];
|
|
44
|
+
render(<VirtualizedRunList runs={runs} />);
|
|
45
|
+
// Both should render in the given order (not re-sorted)
|
|
46
|
+
const links = screen.getAllByTestId('next-link');
|
|
47
|
+
expect(links[0]).toHaveAttribute('href', '/runs/run-b');
|
|
48
|
+
expect(links[1]).toHaveAttribute('href', '/runs/run-a');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts a custom renderItem function', () => {
|
|
52
|
+
const runs = [
|
|
53
|
+
createMockRun({ runId: 'run-custom-1', processId: 'custom' }),
|
|
54
|
+
];
|
|
55
|
+
render(
|
|
56
|
+
<VirtualizedRunList
|
|
57
|
+
runs={runs}
|
|
58
|
+
renderItem={(run) => (
|
|
59
|
+
<div data-testid="custom-item">{run.runId}</div>
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByTestId('custom-item')).toHaveTextContent('run-custom-1');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('virtualized rendering (above threshold)', () => {
|
|
68
|
+
it('renders the virtualized container for large lists', () => {
|
|
69
|
+
const runs = Array.from({ length: 20 }, (_, i) =>
|
|
70
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
71
|
+
);
|
|
72
|
+
render(<VirtualizedRunList runs={runs} maxHeight={300} />);
|
|
73
|
+
expect(screen.getByTestId('run-list-virtualized')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('uses a virtual container with total height for all items', () => {
|
|
77
|
+
const runs = Array.from({ length: 50 }, (_, i) =>
|
|
78
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
79
|
+
);
|
|
80
|
+
render(<VirtualizedRunList runs={runs} maxHeight={300} />);
|
|
81
|
+
const scrollContainer = screen.getByTestId('run-list-virtualized');
|
|
82
|
+
// The inner container should have a height reflecting the total virtual size
|
|
83
|
+
// (50 items * ~140px estimated height = ~7000px), proving items are virtualized
|
|
84
|
+
// rather than rendered in a flat DOM list.
|
|
85
|
+
const innerDiv = scrollContainer.firstElementChild as HTMLElement;
|
|
86
|
+
expect(innerDiv).toBeTruthy();
|
|
87
|
+
const totalHeight = parseInt(innerDiv.style.height, 10);
|
|
88
|
+
// 50 items * 140px estimated = 7000px
|
|
89
|
+
expect(totalHeight).toBeGreaterThanOrEqual(5000);
|
|
90
|
+
// jsdom doesn't support layout so virtualizer may not render actual items,
|
|
91
|
+
// but the container structure proves virtualization is active.
|
|
92
|
+
// In a real browser, only visible + overscan items would be rendered.
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('applies maxHeight to the scroll container', () => {
|
|
96
|
+
const runs = Array.from({ length: 20 }, (_, i) =>
|
|
97
|
+
createMockRun({ runId: `run-${i}`, processId: `process-${i}` })
|
|
98
|
+
);
|
|
99
|
+
render(<VirtualizedRunList runs={runs} maxHeight={400} />);
|
|
100
|
+
const container = screen.getByTestId('run-list-virtualized');
|
|
101
|
+
expect(container.style.maxHeight).toBe('400px');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('stable sort keys', () => {
|
|
106
|
+
it('maintains order when runs are updated without ID changes', () => {
|
|
107
|
+
const runsV1 = [
|
|
108
|
+
createMockRun({ runId: 'run-1', processId: 'alpha', completedTasks: 1 }),
|
|
109
|
+
createMockRun({ runId: 'run-2', processId: 'beta', completedTasks: 2 }),
|
|
110
|
+
createMockRun({ runId: 'run-3', processId: 'gamma', completedTasks: 3 }),
|
|
111
|
+
];
|
|
112
|
+
const { rerender } = render(<VirtualizedRunList runs={runsV1} />);
|
|
113
|
+
|
|
114
|
+
// Update completedTasks but keep same runIds and order
|
|
115
|
+
const runsV2 = [
|
|
116
|
+
createMockRun({ runId: 'run-1', processId: 'alpha', completedTasks: 5 }),
|
|
117
|
+
createMockRun({ runId: 'run-2', processId: 'beta', completedTasks: 6 }),
|
|
118
|
+
createMockRun({ runId: 'run-3', processId: 'gamma', completedTasks: 7 }),
|
|
119
|
+
];
|
|
120
|
+
rerender(<VirtualizedRunList runs={runsV2} />);
|
|
121
|
+
|
|
122
|
+
const links = screen.getAllByTestId('next-link');
|
|
123
|
+
expect(links[0]).toHaveAttribute('href', '/runs/run-1');
|
|
124
|
+
expect(links[1]).toHaveAttribute('href', '/runs/run-2');
|
|
125
|
+
expect(links[2]).toHaveAttribute('href', '/runs/run-3');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles new runs being prepended', () => {
|
|
129
|
+
const initialRuns = [
|
|
130
|
+
createMockRun({ runId: 'run-2', processId: 'beta' }),
|
|
131
|
+
createMockRun({ runId: 'run-3', processId: 'gamma' }),
|
|
132
|
+
];
|
|
133
|
+
const { rerender } = render(<VirtualizedRunList runs={initialRuns} />);
|
|
134
|
+
|
|
135
|
+
// Prepend a new run
|
|
136
|
+
const updatedRuns = [
|
|
137
|
+
createMockRun({ runId: 'run-1', processId: 'alpha' }),
|
|
138
|
+
createMockRun({ runId: 'run-2', processId: 'beta' }),
|
|
139
|
+
createMockRun({ runId: 'run-3', processId: 'gamma' }),
|
|
140
|
+
];
|
|
141
|
+
rerender(<VirtualizedRunList runs={updatedRuns} />);
|
|
142
|
+
|
|
143
|
+
const links = screen.getAllByTestId('next-link');
|
|
144
|
+
expect(links).toHaveLength(3);
|
|
145
|
+
expect(links[0]).toHaveAttribute('href', '/runs/run-1');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('RunCard React.memo', () => {
|
|
151
|
+
it('is wrapped in React.memo (has $$typeof or compare function)', () => {
|
|
152
|
+
// React.memo components have a `type` property and `compare` property
|
|
153
|
+
// We check that RunCard is a memo component by verifying its internal structure
|
|
154
|
+
expect(RunCard).toBeDefined();
|
|
155
|
+
// React.memo wraps the component — the $$typeof for memo is Symbol(react.memo)
|
|
156
|
+
// In the test environment we can check the component's type
|
|
157
|
+
expect((RunCard as any).$$typeof).toBeDefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('skips re-render when props are equal', () => {
|
|
161
|
+
const renderSpy = vi.fn();
|
|
162
|
+
vi.fn(({ run, selected: _selected }: { run: Run; selected?: boolean }) => {
|
|
163
|
+
renderSpy();
|
|
164
|
+
return <div data-testid="spy-card">{run.runId}</div>;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// We test the memo behavior indirectly: the actual RunCard uses memo,
|
|
168
|
+
// but we can verify the comparator function exists
|
|
169
|
+
const run = createMockRun({ runId: 'test-memo' });
|
|
170
|
+
const { rerender } = render(<RunCard run={run} />);
|
|
171
|
+
|
|
172
|
+
// Re-render with same props (same reference)
|
|
173
|
+
rerender(<RunCard run={run} />);
|
|
174
|
+
|
|
175
|
+
// The component should still be in the DOM
|
|
176
|
+
const links = screen.getAllByTestId('next-link');
|
|
177
|
+
expect(links.length).toBeGreaterThan(0);
|
|
178
|
+
});
|
|
179
|
+
});
|