@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,212 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render, screen, setupUser } from '@/test/test-utils';
|
|
4
|
+
import { TaskDetailPanel } from '../task-detail';
|
|
5
|
+
import { createMockTaskDetail } from '@/test/fixtures';
|
|
6
|
+
import type { TaskDetail } from '@/types';
|
|
7
|
+
|
|
8
|
+
// Mock the useTaskDetail hook
|
|
9
|
+
const mockUseTaskDetail = vi.fn<[], { task: TaskDetail | null; loading: boolean; error: string | null }>();
|
|
10
|
+
|
|
11
|
+
vi.mock('@/hooks/use-run-detail', () => ({
|
|
12
|
+
useTaskDetail: (..._args: unknown[]) => mockUseTaskDetail(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock BreakpointPanel to avoid its complex dependencies
|
|
16
|
+
vi.mock('@/components/breakpoint/breakpoint-panel', () => ({
|
|
17
|
+
BreakpointPanel: ({ task: _task }: { task: unknown }) => (
|
|
18
|
+
<div data-testid="breakpoint-panel">Breakpoint Panel Content</div>
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock clipboard
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockUseTaskDetail.mockReset();
|
|
25
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
26
|
+
writable: true,
|
|
27
|
+
configurable: true,
|
|
28
|
+
value: {
|
|
29
|
+
writeText: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
readText: vi.fn().mockResolvedValue(''),
|
|
31
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
read: vi.fn().mockResolvedValue([]),
|
|
33
|
+
addEventListener: vi.fn(),
|
|
34
|
+
removeEventListener: vi.fn(),
|
|
35
|
+
dispatchEvent: vi.fn().mockReturnValue(false),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('TaskDetailPanel', () => {
|
|
41
|
+
it('renders "Click a task to view details" when effectId is null', () => {
|
|
42
|
+
mockUseTaskDetail.mockReturnValue({ task: null, loading: false, error: null });
|
|
43
|
+
render(<TaskDetailPanel runId="run-1" effectId={null} />);
|
|
44
|
+
expect(screen.getByText('Click a task to view details')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders loading spinner when loading and no task', () => {
|
|
48
|
+
mockUseTaskDetail.mockReturnValue({ task: null, loading: true, error: null });
|
|
49
|
+
const { container } = render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
|
|
50
|
+
// Loader2 icon is mocked as svg with data-lucide="Loader2"
|
|
51
|
+
const spinner = container.querySelector('[data-lucide="Loader2"]');
|
|
52
|
+
expect(spinner).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders tab list with Agent, Timing, Logs, Data tabs', () => {
|
|
56
|
+
const task = createMockTaskDetail({ kind: 'node' });
|
|
57
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
58
|
+
render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText('Agent')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText('Timing')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('Logs')).toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText('Data')).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not render Approval tab for non-breakpoint tasks', () => {
|
|
67
|
+
const task = createMockTaskDetail({ kind: 'node' });
|
|
68
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
69
|
+
render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
|
|
70
|
+
expect(screen.queryByText('Approval')).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders Approval tab for breakpoint tasks', () => {
|
|
74
|
+
const task = createMockTaskDetail({ kind: 'breakpoint' });
|
|
75
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
76
|
+
render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
|
|
77
|
+
expect(screen.getByText('Approval')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls onTabChange when switching tabs', async () => {
|
|
81
|
+
const user = setupUser();
|
|
82
|
+
const task = createMockTaskDetail({ kind: 'node' });
|
|
83
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
84
|
+
const onTabChange = vi.fn();
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<TaskDetailPanel
|
|
88
|
+
runId="run-1"
|
|
89
|
+
effectId="eff-1"
|
|
90
|
+
activeTab="agent"
|
|
91
|
+
onTabChange={onTabChange}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const timingTab = screen.getByText('Timing');
|
|
96
|
+
await user.click(timingTab);
|
|
97
|
+
|
|
98
|
+
expect(onTabChange).toHaveBeenCalledWith('timing');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('renders with activeTab controlling which tab content is visible', async () => {
|
|
102
|
+
const task = createMockTaskDetail({
|
|
103
|
+
kind: 'node',
|
|
104
|
+
stdout: 'log output here',
|
|
105
|
+
stderr: undefined,
|
|
106
|
+
});
|
|
107
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
108
|
+
|
|
109
|
+
render(
|
|
110
|
+
<TaskDetailPanel
|
|
111
|
+
runId="run-1"
|
|
112
|
+
effectId="eff-1"
|
|
113
|
+
activeTab="logs"
|
|
114
|
+
onTabChange={vi.fn()}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// LogViewer is lazy-loaded via next/dynamic; wait for it to resolve
|
|
119
|
+
expect(await screen.findByText('stdout')).toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('renders agent panel content when agent tab is active', async () => {
|
|
123
|
+
const task = createMockTaskDetail({
|
|
124
|
+
kind: 'agent',
|
|
125
|
+
title: 'My Agent Task',
|
|
126
|
+
});
|
|
127
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<TaskDetailPanel
|
|
131
|
+
runId="run-1"
|
|
132
|
+
effectId="eff-1"
|
|
133
|
+
activeTab="agent"
|
|
134
|
+
onTabChange={vi.fn()}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// AgentPanel is lazy-loaded via next/dynamic; wait for it to resolve
|
|
139
|
+
expect(await screen.findByText('My Agent Task')).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('renders timing panel content when timing tab is active', async () => {
|
|
143
|
+
const task = createMockTaskDetail({
|
|
144
|
+
kind: 'node',
|
|
145
|
+
duration: 5000,
|
|
146
|
+
});
|
|
147
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
148
|
+
|
|
149
|
+
render(
|
|
150
|
+
<TaskDetailPanel
|
|
151
|
+
runId="run-1"
|
|
152
|
+
effectId="eff-1"
|
|
153
|
+
activeTab="timing"
|
|
154
|
+
onTabChange={vi.fn()}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// TimingPanel is lazy-loaded via next/dynamic; wait for it to resolve
|
|
159
|
+
expect(await screen.findByText('Requested')).toBeInTheDocument();
|
|
160
|
+
expect(screen.getByText('Duration')).toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('renders data panel (JsonTree) when data tab is active', async () => {
|
|
164
|
+
const task = createMockTaskDetail({
|
|
165
|
+
kind: 'node',
|
|
166
|
+
input: { query: 'some-input-data' },
|
|
167
|
+
result: { output: 'result-data' },
|
|
168
|
+
});
|
|
169
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<TaskDetailPanel
|
|
173
|
+
runId="run-1"
|
|
174
|
+
effectId="eff-1"
|
|
175
|
+
activeTab="data"
|
|
176
|
+
onTabChange={vi.fn()}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// JsonTree is lazy-loaded via next/dynamic; wait for it to resolve
|
|
181
|
+
expect(await screen.findByText('Input')).toBeInTheDocument();
|
|
182
|
+
expect(screen.getByText('Output')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not show loading spinner when loading is true but task is already available', () => {
|
|
186
|
+
const task = createMockTaskDetail({ kind: 'node' });
|
|
187
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: true, error: null });
|
|
188
|
+
const { container } = render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
|
|
189
|
+
// Should render tabs, not spinner
|
|
190
|
+
expect(screen.getByText('Agent')).toBeInTheDocument();
|
|
191
|
+
const _spinner = container.querySelector('[data-lucide="Loader2"]');
|
|
192
|
+
// The spinner should not appear (or if it does it's in the tab content, not the loading state)
|
|
193
|
+
expect(screen.queryByText('Click a task to view details')).not.toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('renders breakpoint panel content when breakpoint tab is active', async () => {
|
|
197
|
+
const task = createMockTaskDetail({ kind: 'breakpoint' });
|
|
198
|
+
mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
|
|
199
|
+
|
|
200
|
+
render(
|
|
201
|
+
<TaskDetailPanel
|
|
202
|
+
runId="run-1"
|
|
203
|
+
effectId="eff-1"
|
|
204
|
+
activeTab="breakpoint"
|
|
205
|
+
onTabChange={vi.fn()}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// BreakpointPanel is lazy-loaded via next/dynamic; wait for it to resolve
|
|
210
|
+
expect(await screen.findByTestId('breakpoint-panel')).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import { TimingPanel } from '../timing-panel';
|
|
5
|
+
import { createMockTaskDetail, createMockTaskEffect } from '@/test/fixtures';
|
|
6
|
+
|
|
7
|
+
describe('TimingPanel', () => {
|
|
8
|
+
it('renders null task state with placeholder message', () => {
|
|
9
|
+
render(<TimingPanel task={null} />);
|
|
10
|
+
expect(screen.getByText('Select a task to view timing')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders Requested, Resolved, and Duration rows', () => {
|
|
14
|
+
const task = createMockTaskDetail({ duration: 5000 });
|
|
15
|
+
render(<TimingPanel task={task} />);
|
|
16
|
+
expect(screen.getByText('Requested')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('Resolved')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText('Duration')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('displays formatted duration for a task', () => {
|
|
22
|
+
const task = createMockTaskDetail({ duration: 45000 });
|
|
23
|
+
render(<TimingPanel task={task} />);
|
|
24
|
+
expect(screen.getByText('45s')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('displays formatted timestamp for requestedAt', () => {
|
|
28
|
+
const task = createMockTaskDetail({
|
|
29
|
+
requestedAt: '2026-02-16T10:30:45.000Z',
|
|
30
|
+
});
|
|
31
|
+
render(<TimingPanel task={task} />);
|
|
32
|
+
// formatTimestamp produces locale-specific time string
|
|
33
|
+
expect(screen.getByText('Requested')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('shows "em dash" for missing resolvedAt on running task', () => {
|
|
37
|
+
const task = createMockTaskDetail({
|
|
38
|
+
status: 'requested',
|
|
39
|
+
resolvedAt: undefined,
|
|
40
|
+
finishedAt: undefined,
|
|
41
|
+
duration: 0,
|
|
42
|
+
});
|
|
43
|
+
render(<TimingPanel task={task} />);
|
|
44
|
+
// formatTimestamp returns \u2014 for undefined
|
|
45
|
+
const dashElements = screen.getAllByText('\u2014');
|
|
46
|
+
expect(dashElements.length).toBeGreaterThanOrEqual(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shows exec timing rows when startedAt/finishedAt differ from requestedAt/resolvedAt', () => {
|
|
50
|
+
const task = createMockTaskDetail({
|
|
51
|
+
requestedAt: '2026-02-16T10:00:00.000Z',
|
|
52
|
+
resolvedAt: '2026-02-16T10:01:00.000Z',
|
|
53
|
+
startedAt: '2026-02-16T10:00:05.000Z',
|
|
54
|
+
finishedAt: '2026-02-16T10:00:55.000Z',
|
|
55
|
+
});
|
|
56
|
+
render(<TimingPanel task={task} />);
|
|
57
|
+
expect(screen.getByText('Exec Started')).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByText('Exec Finished')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not show exec timing rows when they match requested/resolved', () => {
|
|
62
|
+
const ts1 = '2026-02-16T10:00:00.000Z';
|
|
63
|
+
const ts2 = '2026-02-16T10:01:00.000Z';
|
|
64
|
+
const task = createMockTaskDetail({
|
|
65
|
+
requestedAt: ts1,
|
|
66
|
+
resolvedAt: ts2,
|
|
67
|
+
startedAt: ts1,
|
|
68
|
+
finishedAt: ts2,
|
|
69
|
+
});
|
|
70
|
+
render(<TimingPanel task={task} />);
|
|
71
|
+
expect(screen.queryByText('Exec Started')).not.toBeInTheDocument();
|
|
72
|
+
expect(screen.queryByText('Exec Finished')).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders cascading timeline bar when allTasks and runDuration are provided', () => {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const tasks = [
|
|
78
|
+
createMockTaskEffect({
|
|
79
|
+
effectId: 'eff-1',
|
|
80
|
+
title: 'Step A',
|
|
81
|
+
requestedAt: new Date(now - 10000).toISOString(),
|
|
82
|
+
resolvedAt: new Date(now - 5000).toISOString(),
|
|
83
|
+
duration: 5000,
|
|
84
|
+
}),
|
|
85
|
+
createMockTaskEffect({
|
|
86
|
+
effectId: 'eff-2',
|
|
87
|
+
title: 'Step B',
|
|
88
|
+
requestedAt: new Date(now - 5000).toISOString(),
|
|
89
|
+
resolvedAt: new Date(now).toISOString(),
|
|
90
|
+
duration: 5000,
|
|
91
|
+
}),
|
|
92
|
+
];
|
|
93
|
+
const task = createMockTaskDetail({
|
|
94
|
+
...tasks[0],
|
|
95
|
+
effectId: 'eff-1',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('Run Timeline')).toBeInTheDocument();
|
|
101
|
+
// Step legend shows step numbers
|
|
102
|
+
expect(screen.getByText(/Step A/)).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByText(/Step B/)).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows step counter for current task in timeline', () => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const tasks = [
|
|
109
|
+
createMockTaskEffect({
|
|
110
|
+
effectId: 'eff-1',
|
|
111
|
+
title: 'First',
|
|
112
|
+
requestedAt: new Date(now - 10000).toISOString(),
|
|
113
|
+
resolvedAt: new Date(now - 5000).toISOString(),
|
|
114
|
+
duration: 5000,
|
|
115
|
+
}),
|
|
116
|
+
createMockTaskEffect({
|
|
117
|
+
effectId: 'eff-2',
|
|
118
|
+
title: 'Second',
|
|
119
|
+
requestedAt: new Date(now - 5000).toISOString(),
|
|
120
|
+
resolvedAt: new Date(now).toISOString(),
|
|
121
|
+
duration: 5000,
|
|
122
|
+
}),
|
|
123
|
+
];
|
|
124
|
+
const task = createMockTaskDetail({
|
|
125
|
+
...tasks[0],
|
|
126
|
+
effectId: 'eff-1',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
|
|
130
|
+
|
|
131
|
+
expect(screen.getByText('Step 1/2')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('shows "% of total" for current task segment', () => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const tasks = [
|
|
137
|
+
createMockTaskEffect({
|
|
138
|
+
effectId: 'eff-1',
|
|
139
|
+
title: 'Only Step',
|
|
140
|
+
requestedAt: new Date(now - 10000).toISOString(),
|
|
141
|
+
resolvedAt: new Date(now).toISOString(),
|
|
142
|
+
duration: 10000,
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
const task = createMockTaskDetail({
|
|
146
|
+
...tasks[0],
|
|
147
|
+
effectId: 'eff-1',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
|
|
151
|
+
|
|
152
|
+
expect(screen.getByText(/of total/)).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does not render timeline when no allTasks provided', () => {
|
|
156
|
+
const task = createMockTaskDetail({ duration: 5000 });
|
|
157
|
+
render(<TimingPanel task={task} runDuration={10000} />);
|
|
158
|
+
expect(screen.queryByText('Run Timeline')).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not render timeline when effectiveRunDuration is 0', () => {
|
|
162
|
+
const task = createMockTaskDetail({ duration: 0 });
|
|
163
|
+
render(<TimingPanel task={task} runDuration={0} allTasks={[]} />);
|
|
164
|
+
expect(screen.queryByText('Run Timeline')).not.toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not show step legend for single-task timeline', () => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const tasks = [
|
|
170
|
+
createMockTaskEffect({
|
|
171
|
+
effectId: 'eff-1',
|
|
172
|
+
title: 'Solo Step',
|
|
173
|
+
requestedAt: new Date(now - 5000).toISOString(),
|
|
174
|
+
resolvedAt: new Date(now).toISOString(),
|
|
175
|
+
duration: 5000,
|
|
176
|
+
}),
|
|
177
|
+
];
|
|
178
|
+
const task = createMockTaskDetail({
|
|
179
|
+
...tasks[0],
|
|
180
|
+
effectId: 'eff-1',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
render(<TimingPanel task={task} runDuration={5000} allTasks={tasks} />);
|
|
184
|
+
|
|
185
|
+
// Legend only shows when segments.length > 1
|
|
186
|
+
// The step counter still shows, but no numbered legend list
|
|
187
|
+
expect(screen.queryByText(/1\. Solo Step/)).not.toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('renders step legend with numbered entries for multi-task timeline', () => {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const tasks = [
|
|
193
|
+
createMockTaskEffect({
|
|
194
|
+
effectId: 'eff-1',
|
|
195
|
+
title: 'Alpha',
|
|
196
|
+
requestedAt: new Date(now - 10000).toISOString(),
|
|
197
|
+
resolvedAt: new Date(now - 5000).toISOString(),
|
|
198
|
+
duration: 5000,
|
|
199
|
+
}),
|
|
200
|
+
createMockTaskEffect({
|
|
201
|
+
effectId: 'eff-2',
|
|
202
|
+
title: 'Beta',
|
|
203
|
+
requestedAt: new Date(now - 5000).toISOString(),
|
|
204
|
+
resolvedAt: new Date(now).toISOString(),
|
|
205
|
+
duration: 5000,
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
const task = createMockTaskDetail({
|
|
209
|
+
...tasks[0],
|
|
210
|
+
effectId: 'eff-1',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
|
|
214
|
+
|
|
215
|
+
expect(screen.getByText(/1\. Alpha/)).toBeInTheDocument();
|
|
216
|
+
expect(screen.getByText(/2\. Beta/)).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('falls back to wall-clock duration when task.duration is 0', () => {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const task = createMockTaskDetail({
|
|
222
|
+
duration: 0,
|
|
223
|
+
requestedAt: new Date(now - 3000).toISOString(),
|
|
224
|
+
resolvedAt: new Date(now).toISOString(),
|
|
225
|
+
});
|
|
226
|
+
render(<TimingPanel task={task} />);
|
|
227
|
+
// Should compute ~3000ms = 3s
|
|
228
|
+
expect(screen.getByText('3s')).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('does not fall back to wall-clock duration when explicit exec timestamps are zero-length', () => {
|
|
232
|
+
const task = createMockTaskDetail({
|
|
233
|
+
duration: 0,
|
|
234
|
+
requestedAt: '2026-02-16T10:00:00.000Z',
|
|
235
|
+
resolvedAt: '2026-02-16T10:05:00.000Z',
|
|
236
|
+
startedAt: '2026-02-16T10:02:00.000Z',
|
|
237
|
+
finishedAt: '2026-02-16T10:02:00.000Z',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
render(<TimingPanel task={task} />);
|
|
241
|
+
|
|
242
|
+
expect(screen.getByText('<1s')).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('uses step colors cycling through STEP_COLORS array', () => {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const tasks = Array.from({ length: 3 }, (_, i) =>
|
|
248
|
+
createMockTaskEffect({
|
|
249
|
+
effectId: `eff-${i}`,
|
|
250
|
+
title: `Step ${i}`,
|
|
251
|
+
requestedAt: new Date(now - (3 - i) * 3000).toISOString(),
|
|
252
|
+
resolvedAt: new Date(now - (2 - i) * 3000).toISOString(),
|
|
253
|
+
duration: 3000,
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
const task = createMockTaskDetail({
|
|
257
|
+
...tasks[0],
|
|
258
|
+
effectId: 'eff-0',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const { container } = render(<TimingPanel task={task} runDuration={9000} allTasks={tasks} />);
|
|
262
|
+
|
|
263
|
+
// There should be timeline segment divs with different bg- color classes
|
|
264
|
+
const segmentDivs = container.querySelectorAll('[title]');
|
|
265
|
+
// Filter to only those with Step title patterns
|
|
266
|
+
const timelineSegments = Array.from(segmentDivs).filter(el =>
|
|
267
|
+
el.getAttribute('title')?.includes('Step')
|
|
268
|
+
);
|
|
269
|
+
expect(timelineSegments.length).toBe(3);
|
|
270
|
+
});
|
|
271
|
+
});
|