@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 { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { render, screen, setupUser } from "@/test/test-utils";
|
|
3
|
+
import { BreakpointApproval } from "../breakpoint-approval";
|
|
4
|
+
import { createMockTaskDetail } from "@/test/fixtures";
|
|
5
|
+
import type { TaskDetail } from "@/types";
|
|
6
|
+
|
|
7
|
+
// Mock the server action
|
|
8
|
+
const mockApproveBreakpoint = vi.fn();
|
|
9
|
+
vi.mock("@/app/actions/approve-breakpoint", () => ({
|
|
10
|
+
approveBreakpoint: (...args: unknown[]) => mockApproveBreakpoint(...args),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("BreakpointApproval", () => {
|
|
14
|
+
const defaultRunId = "run-123";
|
|
15
|
+
|
|
16
|
+
function makeBreakpointTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
17
|
+
return createMockTaskDetail({
|
|
18
|
+
kind: "breakpoint",
|
|
19
|
+
status: "requested",
|
|
20
|
+
breakpointQuestion: "Should we deploy to production?",
|
|
21
|
+
title: "Deploy Approval",
|
|
22
|
+
breakpoint: {
|
|
23
|
+
question: "Should we deploy to production?",
|
|
24
|
+
title: "Deploy Approval",
|
|
25
|
+
context: { files: [] },
|
|
26
|
+
},
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
mockApproveBreakpoint.mockResolvedValue({ success: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
// Rendering
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
it("renders for a waiting breakpoint task", () => {
|
|
41
|
+
const task = makeBreakpointTask();
|
|
42
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByTestId("breakpoint-approval")).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByTestId("approve-btn")).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByTestId("custom-answer-input")).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not render for a resolved task", () => {
|
|
50
|
+
const task = makeBreakpointTask({ status: "resolved" });
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<BreakpointApproval task={task} runId={defaultRunId} />
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(container.innerHTML).toBe("");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not render for an error task", () => {
|
|
59
|
+
const task = makeBreakpointTask({ status: "error" });
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<BreakpointApproval task={task} runId={defaultRunId} />
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(container.innerHTML).toBe("");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// Option buttons
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
it("renders option buttons when options are provided", () => {
|
|
72
|
+
const task = makeBreakpointTask({
|
|
73
|
+
breakpoint: {
|
|
74
|
+
question: "Choose environment",
|
|
75
|
+
title: "Deploy",
|
|
76
|
+
options: ["staging", "production"],
|
|
77
|
+
context: { files: [] },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
81
|
+
|
|
82
|
+
expect(screen.getByTestId("breakpoint-options")).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByTestId("option-btn-staging")).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByTestId("option-btn-production")).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not render option section when no options", () => {
|
|
88
|
+
const task = makeBreakpointTask();
|
|
89
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
90
|
+
|
|
91
|
+
expect(screen.queryByTestId("breakpoint-options")).not.toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls server action when an option button is clicked", async () => {
|
|
95
|
+
const user = setupUser();
|
|
96
|
+
const task = makeBreakpointTask({
|
|
97
|
+
breakpoint: {
|
|
98
|
+
question: "Choose environment",
|
|
99
|
+
title: "Deploy",
|
|
100
|
+
options: ["staging", "production"],
|
|
101
|
+
context: { files: [] },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
105
|
+
|
|
106
|
+
await user.click(screen.getByTestId("option-btn-staging"));
|
|
107
|
+
|
|
108
|
+
expect(mockApproveBreakpoint).toHaveBeenCalledWith(
|
|
109
|
+
defaultRunId,
|
|
110
|
+
task.effectId,
|
|
111
|
+
"staging"
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Custom answer
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
it("disables the approve button when input is empty", () => {
|
|
120
|
+
const task = makeBreakpointTask();
|
|
121
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
122
|
+
|
|
123
|
+
expect(screen.getByTestId("approve-btn")).toBeDisabled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("enables the approve button when input has text", async () => {
|
|
127
|
+
const user = setupUser();
|
|
128
|
+
const task = makeBreakpointTask();
|
|
129
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
130
|
+
|
|
131
|
+
await user.type(screen.getByTestId("custom-answer-input"), "yes");
|
|
132
|
+
|
|
133
|
+
expect(screen.getByTestId("approve-btn")).not.toBeDisabled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("calls server action on form submit with custom answer", async () => {
|
|
137
|
+
const user = setupUser();
|
|
138
|
+
const task = makeBreakpointTask();
|
|
139
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
140
|
+
|
|
141
|
+
await user.type(screen.getByTestId("custom-answer-input"), "Deploy to staging");
|
|
142
|
+
await user.click(screen.getByTestId("approve-btn"));
|
|
143
|
+
|
|
144
|
+
expect(mockApproveBreakpoint).toHaveBeenCalledWith(
|
|
145
|
+
defaultRunId,
|
|
146
|
+
task.effectId,
|
|
147
|
+
"Deploy to staging"
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
// Result feedback
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
it("shows success message after approval", async () => {
|
|
156
|
+
const user = setupUser();
|
|
157
|
+
mockApproveBreakpoint.mockResolvedValue({ success: true });
|
|
158
|
+
|
|
159
|
+
const task = makeBreakpointTask();
|
|
160
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
161
|
+
|
|
162
|
+
await user.type(screen.getByTestId("custom-answer-input"), "yes");
|
|
163
|
+
await user.click(screen.getByTestId("approve-btn"));
|
|
164
|
+
|
|
165
|
+
const resultEl = await screen.findByTestId("approval-result");
|
|
166
|
+
expect(resultEl).toBeInTheDocument();
|
|
167
|
+
expect(resultEl.textContent).toContain("approved successfully");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("shows error message on failure", async () => {
|
|
171
|
+
const user = setupUser();
|
|
172
|
+
mockApproveBreakpoint.mockResolvedValue({
|
|
173
|
+
success: false,
|
|
174
|
+
error: "Task directory not found",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const task = makeBreakpointTask();
|
|
178
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
179
|
+
|
|
180
|
+
await user.type(screen.getByTestId("custom-answer-input"), "yes");
|
|
181
|
+
await user.click(screen.getByTestId("approve-btn"));
|
|
182
|
+
|
|
183
|
+
const resultEl = await screen.findByTestId("approval-result");
|
|
184
|
+
expect(resultEl).toBeInTheDocument();
|
|
185
|
+
expect(resultEl.textContent).toContain("Task directory not found");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// Label text
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
it('shows "Or provide a custom answer" when options exist', () => {
|
|
193
|
+
const task = makeBreakpointTask({
|
|
194
|
+
breakpoint: {
|
|
195
|
+
question: "Pick",
|
|
196
|
+
title: "Pick",
|
|
197
|
+
options: ["a", "b"],
|
|
198
|
+
context: { files: [] },
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
202
|
+
|
|
203
|
+
expect(screen.getByText("Or provide a custom answer")).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('shows "Provide an answer" when no options exist', () => {
|
|
207
|
+
const task = makeBreakpointTask();
|
|
208
|
+
render(<BreakpointApproval task={task} runId={defaultRunId} />);
|
|
209
|
+
|
|
210
|
+
expect(screen.getByText("Provide an answer")).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { render, screen } from '@/test/test-utils';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { BreakpointPanel } from '../breakpoint-panel';
|
|
4
|
+
import { createMockTaskDetail } from '@/test/fixtures';
|
|
5
|
+
import type { TaskDetail } from '@/types';
|
|
6
|
+
|
|
7
|
+
// Mock the server action used by BreakpointApproval
|
|
8
|
+
vi.mock('@/app/actions/approve-breakpoint', () => ({
|
|
9
|
+
approveBreakpoint: vi.fn().mockResolvedValue({ success: true }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('BreakpointPanel', () => {
|
|
13
|
+
const defaultRunId = 'run-123';
|
|
14
|
+
|
|
15
|
+
function makeBreakpointTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
16
|
+
return createMockTaskDetail({
|
|
17
|
+
kind: 'breakpoint',
|
|
18
|
+
status: 'requested',
|
|
19
|
+
breakpointQuestion: 'Should we deploy to production?',
|
|
20
|
+
title: 'Deploy Approval',
|
|
21
|
+
breakpoint: {
|
|
22
|
+
question: 'Should we deploy to production?',
|
|
23
|
+
title: 'Deploy Approval',
|
|
24
|
+
context: { files: [] },
|
|
25
|
+
},
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
// Rendering
|
|
32
|
+
// -----------------------------------------------------------------------
|
|
33
|
+
it('renders the breakpoint title', () => {
|
|
34
|
+
const task = makeBreakpointTask();
|
|
35
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
36
|
+
|
|
37
|
+
expect(screen.getByText('Deploy Approval')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders the breakpoint question', () => {
|
|
41
|
+
const task = makeBreakpointTask();
|
|
42
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText('Should we deploy to production?')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the "Breakpoint" badge', () => {
|
|
48
|
+
const task = makeBreakpointTask();
|
|
49
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
50
|
+
|
|
51
|
+
expect(screen.getByText('Breakpoint')).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the "Awaiting decision" label', () => {
|
|
55
|
+
const task = makeBreakpointTask();
|
|
56
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText('Awaiting decision')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// -----------------------------------------------------------------------
|
|
62
|
+
// Approval form for pending breakpoints
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
it('renders approval form for requested breakpoints', () => {
|
|
65
|
+
const task = makeBreakpointTask({ status: 'requested' });
|
|
66
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
67
|
+
|
|
68
|
+
expect(screen.getByTestId('breakpoint-approval')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByTestId('approve-btn')).toBeInTheDocument();
|
|
70
|
+
expect(screen.queryByText('Reject')).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not render approval form for resolved breakpoints', () => {
|
|
74
|
+
const task = makeBreakpointTask({ status: 'resolved' });
|
|
75
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
76
|
+
|
|
77
|
+
expect(screen.queryByTestId('breakpoint-approval')).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// -----------------------------------------------------------------------
|
|
81
|
+
// Already resolved state (task.status === 'resolved')
|
|
82
|
+
// -----------------------------------------------------------------------
|
|
83
|
+
it('shows "Already Resolved" badge when task.status is resolved', () => {
|
|
84
|
+
const task = makeBreakpointTask({ status: 'resolved' });
|
|
85
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText('Already Resolved')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
// Resolved state display
|
|
92
|
+
// -----------------------------------------------------------------------
|
|
93
|
+
it('shows success message when task is resolved', () => {
|
|
94
|
+
const task = makeBreakpointTask({ status: 'resolved' });
|
|
95
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
96
|
+
|
|
97
|
+
expect(screen.getByText('Approved')).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText('Breakpoint has been resolved')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------------
|
|
102
|
+
// Fallback question text
|
|
103
|
+
// -----------------------------------------------------------------------
|
|
104
|
+
it('falls back to breakpointQuestion when breakpoint payload is missing', () => {
|
|
105
|
+
const task = createMockTaskDetail({
|
|
106
|
+
kind: 'breakpoint',
|
|
107
|
+
status: 'requested',
|
|
108
|
+
breakpointQuestion: 'Fallback question?',
|
|
109
|
+
title: 'Fallback Title',
|
|
110
|
+
breakpoint: undefined,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
114
|
+
|
|
115
|
+
expect(screen.getByText('Fallback question?')).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('falls back to "Approval required" when no question is provided', () => {
|
|
119
|
+
const task = createMockTaskDetail({
|
|
120
|
+
kind: 'breakpoint',
|
|
121
|
+
status: 'requested',
|
|
122
|
+
breakpointQuestion: undefined,
|
|
123
|
+
breakpoint: undefined,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
render(<BreakpointPanel task={task} runId={defaultRunId} />);
|
|
127
|
+
|
|
128
|
+
expect(screen.getByText('Approval required')).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { render, screen, setupUser } from '@/test/test-utils';
|
|
2
|
+
import { FilePreview } from '../file-preview';
|
|
3
|
+
import type { BreakpointFile } from '@/types';
|
|
4
|
+
|
|
5
|
+
describe('FilePreview', () => {
|
|
6
|
+
const defaultProps = {
|
|
7
|
+
runId: 'run-1',
|
|
8
|
+
effectId: 'eff-1',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
// Reset global fetch mock
|
|
14
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// -----------------------------------------------------------------------
|
|
22
|
+
// Null rendering
|
|
23
|
+
// -----------------------------------------------------------------------
|
|
24
|
+
it('returns null when files array is empty', () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<FilePreview files={[]} {...defaultProps} />,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(container.innerHTML).toBe('');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// -----------------------------------------------------------------------
|
|
33
|
+
// Renders file list
|
|
34
|
+
// -----------------------------------------------------------------------
|
|
35
|
+
it('renders "Attached Files" heading', () => {
|
|
36
|
+
const files: BreakpointFile[] = [
|
|
37
|
+
{ path: 'src/index.ts', format: 'code', language: 'typescript' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('Attached Files')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders file paths in the list', () => {
|
|
46
|
+
const files: BreakpointFile[] = [
|
|
47
|
+
{ path: 'src/main.ts', format: 'code' },
|
|
48
|
+
{ path: 'README.md', format: 'markdown' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
52
|
+
|
|
53
|
+
expect(screen.getByText('src/main.ts')).toBeInTheDocument();
|
|
54
|
+
expect(screen.getByText('README.md')).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// -----------------------------------------------------------------------
|
|
58
|
+
// Format badges
|
|
59
|
+
// -----------------------------------------------------------------------
|
|
60
|
+
it('displays format badges for each file', () => {
|
|
61
|
+
const files: BreakpointFile[] = [
|
|
62
|
+
{ path: 'config.json', format: 'json' },
|
|
63
|
+
{ path: 'notes.md', format: 'markdown' },
|
|
64
|
+
{ path: 'script.sh', format: 'code' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText('json')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('markdown')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('code')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -----------------------------------------------------------------------
|
|
75
|
+
// Language badge
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
it('displays language badge when language is specified', () => {
|
|
78
|
+
const files: BreakpointFile[] = [
|
|
79
|
+
{ path: 'app.py', format: 'code', language: 'python' },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByText('python')).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not display language badge when language is not specified', () => {
|
|
88
|
+
const files: BreakpointFile[] = [
|
|
89
|
+
{ path: 'data.json', format: 'json' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
93
|
+
|
|
94
|
+
// Only the format badge should be present
|
|
95
|
+
expect(screen.getByText('json')).toBeInTheDocument();
|
|
96
|
+
// No language badge -- check there's only one badge-like element for this file
|
|
97
|
+
expect(screen.queryByText('python')).not.toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// -----------------------------------------------------------------------
|
|
101
|
+
// Icons by format
|
|
102
|
+
// -----------------------------------------------------------------------
|
|
103
|
+
it('renders markdown icon for markdown files', () => {
|
|
104
|
+
const files: BreakpointFile[] = [
|
|
105
|
+
{ path: 'README.md', format: 'markdown' },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByTestId('icon-FileText')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders json icon for json files', () => {
|
|
114
|
+
const files: BreakpointFile[] = [
|
|
115
|
+
{ path: 'config.json', format: 'json' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
119
|
+
|
|
120
|
+
expect(screen.getByTestId('icon-FileJson')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders code icon for code files', () => {
|
|
124
|
+
const files: BreakpointFile[] = [
|
|
125
|
+
{ path: 'main.ts', format: 'code' },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
129
|
+
|
|
130
|
+
expect(screen.getByTestId('icon-Code')).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('renders fallback icon for unknown format', () => {
|
|
134
|
+
const files: BreakpointFile[] = [
|
|
135
|
+
{ path: 'file.xyz', format: 'unknown' },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
139
|
+
|
|
140
|
+
// Fallback uses FileText icon
|
|
141
|
+
expect(screen.getByTestId('icon-FileText')).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// File content loading via accordion expand
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
it('fetches and displays file content when accordion item is expanded', async () => {
|
|
148
|
+
const user = setupUser();
|
|
149
|
+
const mockContent = 'const x = 42;';
|
|
150
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
151
|
+
Promise.resolve(new Response(JSON.stringify({ content: mockContent }), {
|
|
152
|
+
status: 200,
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
})),
|
|
155
|
+
));
|
|
156
|
+
|
|
157
|
+
const files: BreakpointFile[] = [
|
|
158
|
+
{ path: 'src/index.ts', format: 'code', language: 'typescript' },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
162
|
+
|
|
163
|
+
// Click the accordion trigger to expand
|
|
164
|
+
await user.click(screen.getByText('src/index.ts'));
|
|
165
|
+
|
|
166
|
+
// Wait for the content to appear
|
|
167
|
+
expect(await screen.findByText('const x = 42;')).toBeInTheDocument();
|
|
168
|
+
|
|
169
|
+
// Verify fetch was called with the correct URL
|
|
170
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
171
|
+
'/api/runs/run-1/tasks/eff-1?file=src%2Findex.ts',
|
|
172
|
+
expect.anything(),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('shows fallback text when content fetch fails', async () => {
|
|
177
|
+
const user = setupUser();
|
|
178
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
179
|
+
Promise.resolve(new Response('Unprocessable Entity', {
|
|
180
|
+
status: 422,
|
|
181
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
182
|
+
})),
|
|
183
|
+
));
|
|
184
|
+
|
|
185
|
+
const files: BreakpointFile[] = [
|
|
186
|
+
{ path: 'broken.ts', format: 'code' },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
190
|
+
|
|
191
|
+
await user.click(screen.getByText('broken.ts'));
|
|
192
|
+
|
|
193
|
+
expect(await screen.findByText('// Failed to load file content')).toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('shows "No content available" when API returns empty content', async () => {
|
|
197
|
+
const user = setupUser();
|
|
198
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
199
|
+
Promise.resolve(new Response(JSON.stringify({}), {
|
|
200
|
+
status: 200,
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
})),
|
|
203
|
+
));
|
|
204
|
+
|
|
205
|
+
const files: BreakpointFile[] = [
|
|
206
|
+
{ path: 'empty.ts', format: 'code' },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
210
|
+
|
|
211
|
+
await user.click(screen.getByText('empty.ts'));
|
|
212
|
+
|
|
213
|
+
expect(await screen.findByText('// No content available')).toBeInTheDocument();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// -----------------------------------------------------------------------
|
|
217
|
+
// JSON formatting
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
it('pretty-prints JSON content', async () => {
|
|
220
|
+
const user = setupUser();
|
|
221
|
+
const jsonContent = '{"key":"value","num":42}';
|
|
222
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
223
|
+
Promise.resolve(new Response(JSON.stringify({ content: jsonContent }), {
|
|
224
|
+
status: 200,
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
})),
|
|
227
|
+
));
|
|
228
|
+
|
|
229
|
+
const files: BreakpointFile[] = [
|
|
230
|
+
{ path: 'data.json', format: 'json' },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
234
|
+
|
|
235
|
+
await user.click(screen.getByText('data.json'));
|
|
236
|
+
|
|
237
|
+
// Pretty-printed JSON includes the key on a separate line
|
|
238
|
+
expect(await screen.findByText(/"key": "value",/)).toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// -----------------------------------------------------------------------
|
|
242
|
+
// Markdown rendering
|
|
243
|
+
// -----------------------------------------------------------------------
|
|
244
|
+
it('renders markdown content as preformatted text', async () => {
|
|
245
|
+
const user = setupUser();
|
|
246
|
+
const mdContent = '# Hello World\nSome paragraph text';
|
|
247
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
248
|
+
Promise.resolve(new Response(JSON.stringify({ content: mdContent }), {
|
|
249
|
+
status: 200,
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
})),
|
|
252
|
+
));
|
|
253
|
+
|
|
254
|
+
const files: BreakpointFile[] = [
|
|
255
|
+
{ path: 'notes.md', format: 'markdown' },
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
259
|
+
|
|
260
|
+
await user.click(screen.getByText('notes.md'));
|
|
261
|
+
|
|
262
|
+
expect(await screen.findByText(/# Hello World/)).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// -----------------------------------------------------------------------
|
|
266
|
+
// Code content with line numbers
|
|
267
|
+
// -----------------------------------------------------------------------
|
|
268
|
+
it('renders code content with line numbers', async () => {
|
|
269
|
+
const user = setupUser();
|
|
270
|
+
const codeContent = 'line one\nline two\nline three';
|
|
271
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
|
272
|
+
Promise.resolve(new Response(JSON.stringify({ content: codeContent }), {
|
|
273
|
+
status: 200,
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
})),
|
|
276
|
+
));
|
|
277
|
+
|
|
278
|
+
const files: BreakpointFile[] = [
|
|
279
|
+
{ path: 'main.ts', format: 'code' },
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
283
|
+
|
|
284
|
+
await user.click(screen.getByText('main.ts'));
|
|
285
|
+
|
|
286
|
+
// Line numbers should be rendered
|
|
287
|
+
expect(await screen.findByText('1')).toBeInTheDocument();
|
|
288
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
289
|
+
expect(screen.getByText('3')).toBeInTheDocument();
|
|
290
|
+
|
|
291
|
+
// Content should be rendered
|
|
292
|
+
expect(screen.getByText('line one')).toBeInTheDocument();
|
|
293
|
+
expect(screen.getByText('line two')).toBeInTheDocument();
|
|
294
|
+
expect(screen.getByText('line three')).toBeInTheDocument();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// -----------------------------------------------------------------------
|
|
298
|
+
// Multiple files
|
|
299
|
+
// -----------------------------------------------------------------------
|
|
300
|
+
it('renders multiple files in the accordion', () => {
|
|
301
|
+
const files: BreakpointFile[] = [
|
|
302
|
+
{ path: 'file1.ts', format: 'code' },
|
|
303
|
+
{ path: 'file2.json', format: 'json' },
|
|
304
|
+
{ path: 'file3.md', format: 'markdown' },
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
render(<FilePreview files={files} {...defaultProps} />);
|
|
308
|
+
|
|
309
|
+
expect(screen.getByText('file1.ts')).toBeInTheDocument();
|
|
310
|
+
expect(screen.getByText('file2.json')).toBeInTheDocument();
|
|
311
|
+
expect(screen.getByText('file3.md')).toBeInTheDocument();
|
|
312
|
+
});
|
|
313
|
+
});
|