@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,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useRef, useCallback, startTransition } from "react";
|
|
3
|
+
import { subscribe, StreamEvent } from "./use-event-stream";
|
|
4
|
+
import { resilientFetch } from "@/lib/fetcher";
|
|
5
|
+
|
|
6
|
+
interface UseSmartPollingOptions {
|
|
7
|
+
interval?: number;
|
|
8
|
+
sseFilter?: (event: StreamEvent) => boolean;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** When true, suppress SSE-triggered refetches (used during catch-up mode). */
|
|
11
|
+
suppressSseRefetch?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useSmartPolling<T>(
|
|
15
|
+
url: string,
|
|
16
|
+
options: UseSmartPollingOptions = {}
|
|
17
|
+
) {
|
|
18
|
+
const { interval = 5000, sseFilter, enabled = true, suppressSseRefetch = false } = options;
|
|
19
|
+
const [data, setData] = useState<T | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(enabled && !!url);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const mountedRef = useRef(true);
|
|
23
|
+
const sseFilterRef = useRef(sseFilter);
|
|
24
|
+
sseFilterRef.current = sseFilter;
|
|
25
|
+
const sseConnected = useRef(false);
|
|
26
|
+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
27
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
28
|
+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
const suppressSseRefetchRef = useRef(suppressSseRefetch);
|
|
30
|
+
suppressSseRefetchRef.current = suppressSseRefetch;
|
|
31
|
+
|
|
32
|
+
const fetchData = useCallback(async () => {
|
|
33
|
+
if (!url || !enabled) return;
|
|
34
|
+
abortRef.current?.abort();
|
|
35
|
+
abortRef.current = new AbortController();
|
|
36
|
+
const result = await resilientFetch<T>(url, { signal: abortRef.current.signal });
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
if (result.error.isAborted) return;
|
|
39
|
+
if (mountedRef.current) {
|
|
40
|
+
setError(result.error.message);
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (mountedRef.current) {
|
|
46
|
+
// Skip state update on 304 Not Modified — data is identical to
|
|
47
|
+
// what we already have, so avoid triggering a re-render cascade.
|
|
48
|
+
if (result.status !== 304) {
|
|
49
|
+
// Use startTransition so SSE-triggered data updates are treated as
|
|
50
|
+
// non-urgent. This keeps the UI responsive during rapid bursts —
|
|
51
|
+
// React can batch and defer these renders without blocking user input.
|
|
52
|
+
startTransition(() => {
|
|
53
|
+
setData(result.data);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
setError(null);
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}, [url, enabled]);
|
|
60
|
+
|
|
61
|
+
// Polling: slower when SSE connected (30s heartbeat), normal interval when disconnected
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
mountedRef.current = true;
|
|
64
|
+
if (!enabled || !url) {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setLoading(true);
|
|
70
|
+
fetchData();
|
|
71
|
+
|
|
72
|
+
// Start poll timer — use longer interval when SSE connected, normal when not
|
|
73
|
+
function startPoll() {
|
|
74
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
|
75
|
+
const pollInterval = sseConnected.current ? Math.max(interval * 3, 15000) : interval;
|
|
76
|
+
pollTimerRef.current = setInterval(fetchData, pollInterval);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startPoll();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
mountedRef.current = false;
|
|
83
|
+
abortRef.current?.abort();
|
|
84
|
+
if (pollTimerRef.current) {
|
|
85
|
+
clearInterval(pollTimerRef.current);
|
|
86
|
+
pollTimerRef.current = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}, [fetchData, interval, enabled, url]);
|
|
90
|
+
|
|
91
|
+
// SSE subscription: triggers immediate refresh on matching events
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!enabled || !sseFilterRef.current) return;
|
|
94
|
+
|
|
95
|
+
const unsubscribe = subscribe((event: StreamEvent) => {
|
|
96
|
+
// Track SSE connection state
|
|
97
|
+
if (event.type === "connected") {
|
|
98
|
+
if (!sseConnected.current) {
|
|
99
|
+
sseConnected.current = true;
|
|
100
|
+
// Restart poll with longer interval since SSE is active
|
|
101
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
|
102
|
+
const slowInterval = Math.max(interval * 3, 15000);
|
|
103
|
+
pollTimerRef.current = setInterval(fetchData, slowInterval);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// On SSE disconnect or error, reset to normal polling (no immediate fetch)
|
|
109
|
+
if (event.type === "disconnect" || event.type === "error") {
|
|
110
|
+
if (sseConnected.current) {
|
|
111
|
+
sseConnected.current = false;
|
|
112
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
|
113
|
+
pollTimerRef.current = setInterval(fetchData, interval);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Apply filter and trigger debounced refresh to coalesce rapid events.
|
|
119
|
+
// Uses a 1500ms trailing-edge debounce (up from 150ms) to batch burst
|
|
120
|
+
// updates that arrive in quick succession (e.g. after overnight runs).
|
|
121
|
+
// When catch-up mode is active, SSE-triggered refetches are suppressed
|
|
122
|
+
// entirely — the catch-up flush will trigger a single refresh instead.
|
|
123
|
+
if (sseFilterRef.current?.(event)) {
|
|
124
|
+
if (suppressSseRefetchRef.current) return;
|
|
125
|
+
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
|
126
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
127
|
+
debounceTimerRef.current = null;
|
|
128
|
+
fetchData();
|
|
129
|
+
}, 1500);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
unsubscribe();
|
|
135
|
+
sseConnected.current = false;
|
|
136
|
+
if (debounceTimerRef.current) {
|
|
137
|
+
clearTimeout(debounceTimerRef.current);
|
|
138
|
+
debounceTimerRef.current = null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}, [enabled, fetchData, interval]);
|
|
142
|
+
|
|
143
|
+
return { data, loading, error, refresh: fetchData };
|
|
144
|
+
}
|
package/src/lib/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cn } from '../cn';
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('returns an empty string when called with no arguments', () => {
|
|
6
|
+
expect(cn()).toBe('');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns a single class name unchanged', () => {
|
|
10
|
+
expect(cn('text-red-500')).toBe('text-red-500');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('merges multiple class names', () => {
|
|
14
|
+
const result = cn('px-2', 'py-1');
|
|
15
|
+
expect(result).toContain('px-2');
|
|
16
|
+
expect(result).toContain('py-1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('handles conditional classes via clsx syntax', () => {
|
|
20
|
+
expect(cn('base', false && 'hidden')).toBe('base');
|
|
21
|
+
expect(cn('base', true && 'visible')).toBe('base visible');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('handles undefined and null inputs', () => {
|
|
25
|
+
expect(cn('base', undefined, null, 'extra')).toBe('base extra');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles array inputs', () => {
|
|
29
|
+
expect(cn(['px-2', 'py-1'])).toContain('px-2');
|
|
30
|
+
expect(cn(['px-2', 'py-1'])).toContain('py-1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles object inputs', () => {
|
|
34
|
+
expect(cn({ 'text-red-500': true, hidden: false })).toBe('text-red-500');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('merges conflicting Tailwind classes correctly (tailwind-merge)', () => {
|
|
38
|
+
// tailwind-merge should resolve conflicts, last one wins
|
|
39
|
+
expect(cn('px-2', 'px-4')).toBe('px-4');
|
|
40
|
+
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('merges conflicting padding classes', () => {
|
|
44
|
+
expect(cn('p-4', 'px-2')).toBe('p-4 px-2');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('deduplicates identical class names', () => {
|
|
48
|
+
// tailwind-merge handles dedup
|
|
49
|
+
const result = cn('flex', 'flex');
|
|
50
|
+
expect(result).toBe('flex');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles empty strings', () => {
|
|
54
|
+
expect(cn('', 'base', '')).toBe('base');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles complex mixed inputs', () => {
|
|
58
|
+
const result = cn(
|
|
59
|
+
'base-class',
|
|
60
|
+
undefined,
|
|
61
|
+
{ 'conditional-class': true, 'excluded-class': false },
|
|
62
|
+
['array-class'],
|
|
63
|
+
);
|
|
64
|
+
expect(result).toContain('base-class');
|
|
65
|
+
expect(result).toContain('conditional-class');
|
|
66
|
+
expect(result).not.toContain('excluded-class');
|
|
67
|
+
expect(result).toContain('array-class');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import {
|
|
5
|
+
getConfig,
|
|
6
|
+
invalidateConfigCache,
|
|
7
|
+
writeConfig,
|
|
8
|
+
} from '../config-loader';
|
|
9
|
+
|
|
10
|
+
// Use vi.spyOn so both test and config-loader module share the same mock references
|
|
11
|
+
const mockReadFile = vi.spyOn(fs, 'readFile');
|
|
12
|
+
const mockWriteFile = vi.spyOn(fs, 'writeFile');
|
|
13
|
+
const mockMkdir = vi.spyOn(fs, 'mkdir');
|
|
14
|
+
|
|
15
|
+
describe('config-loader', () => {
|
|
16
|
+
const originalEnv = process.env;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetAllMocks();
|
|
20
|
+
process.env = { ...originalEnv };
|
|
21
|
+
delete process.env.OBSERVER_REGISTRY;
|
|
22
|
+
delete process.env.OBSERVER_WATCH_DIR;
|
|
23
|
+
delete process.env.WATCH_DIR;
|
|
24
|
+
delete process.env.WATCH_DIRS;
|
|
25
|
+
delete process.env.OBSERVER_PORT;
|
|
26
|
+
delete process.env.PORT;
|
|
27
|
+
delete process.env.OBSERVER_POLL_INTERVAL;
|
|
28
|
+
delete process.env.POLL_INTERVAL;
|
|
29
|
+
delete process.env.OBSERVER_DEFAULT_THEME;
|
|
30
|
+
delete process.env.THEME;
|
|
31
|
+
invalidateConfigCache();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
process.env = originalEnv;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// -----------------------------------------------------------------------
|
|
39
|
+
// invalidateConfigCache
|
|
40
|
+
// -----------------------------------------------------------------------
|
|
41
|
+
describe('invalidateConfigCache', () => {
|
|
42
|
+
it('clears the cached config so next getConfig re-reads', async () => {
|
|
43
|
+
mockReadFile.mockRejectedValue(new Error('no file'));
|
|
44
|
+
const config1 = await getConfig();
|
|
45
|
+
expect(config1).toBeDefined();
|
|
46
|
+
|
|
47
|
+
invalidateConfigCache();
|
|
48
|
+
|
|
49
|
+
mockReadFile.mockRejectedValue(new Error('no file'));
|
|
50
|
+
const _config2 = await getConfig();
|
|
51
|
+
expect(mockReadFile).toHaveBeenCalledTimes(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// -----------------------------------------------------------------------
|
|
56
|
+
// getConfig
|
|
57
|
+
// -----------------------------------------------------------------------
|
|
58
|
+
describe('getConfig', () => {
|
|
59
|
+
it('returns default config when registry file does not exist', async () => {
|
|
60
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
61
|
+
|
|
62
|
+
const config = await getConfig();
|
|
63
|
+
|
|
64
|
+
expect(config).toBeDefined();
|
|
65
|
+
expect(config.port).toBe(4800);
|
|
66
|
+
expect(config.pollInterval).toBe(2000);
|
|
67
|
+
expect(config.theme).toBe('dark');
|
|
68
|
+
expect(config.sources.length).toBeGreaterThan(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses parent of cwd as default source when no env vars are set', async () => {
|
|
72
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
73
|
+
|
|
74
|
+
const config = await getConfig();
|
|
75
|
+
|
|
76
|
+
expect(config.sources[0].path).toBe(path.resolve(process.cwd(), '..'));
|
|
77
|
+
expect(config.sources[0].label).toBe('parent');
|
|
78
|
+
expect(config.sources[0].depth).toBe(3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses OBSERVER_WATCH_DIR env var when set', async () => {
|
|
82
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
83
|
+
process.env.OBSERVER_WATCH_DIR = '/custom/watch/dir';
|
|
84
|
+
invalidateConfigCache();
|
|
85
|
+
|
|
86
|
+
const config = await getConfig();
|
|
87
|
+
|
|
88
|
+
expect(config.sources[0].path).toBe('/custom/watch/dir');
|
|
89
|
+
expect(config.sources[0].label).toBe('cli');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reads sources from registry file', async () => {
|
|
93
|
+
mockReadFile.mockResolvedValue(
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
sources: [
|
|
96
|
+
{ path: '/registered/path', depth: 3, label: 'registry' },
|
|
97
|
+
],
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const config = await getConfig();
|
|
102
|
+
|
|
103
|
+
expect(config.sources).toHaveLength(1);
|
|
104
|
+
expect(config.sources[0].path).toBe('/registered/path');
|
|
105
|
+
expect(config.sources[0].depth).toBe(3);
|
|
106
|
+
expect(config.sources[0].label).toBe('registry');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('defaults depth to 2 when registry source has no depth', async () => {
|
|
110
|
+
mockReadFile.mockResolvedValue(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
sources: [{ path: '/some/path' }],
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const config = await getConfig();
|
|
117
|
+
|
|
118
|
+
expect(config.sources[0].depth).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('caches config and returns cached version within TTL', async () => {
|
|
122
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
|
|
123
|
+
|
|
124
|
+
const config1 = await getConfig();
|
|
125
|
+
const config2 = await getConfig();
|
|
126
|
+
|
|
127
|
+
expect(mockReadFile).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(config1).toBe(config2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('reads theme from registry file', async () => {
|
|
132
|
+
mockReadFile.mockResolvedValue(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
sources: [],
|
|
135
|
+
theme: 'light',
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const config = await getConfig();
|
|
140
|
+
|
|
141
|
+
expect(config.theme).toBe('light');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('defaults theme to dark for invalid theme values', async () => {
|
|
145
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
|
|
146
|
+
process.env.THEME = 'invalid-theme';
|
|
147
|
+
invalidateConfigCache();
|
|
148
|
+
|
|
149
|
+
const config = await getConfig();
|
|
150
|
+
|
|
151
|
+
expect(config.theme).toBe('dark');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
// writeConfig
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
describe('writeConfig', () => {
|
|
159
|
+
it('creates directory and writes config file', async () => {
|
|
160
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
161
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
162
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
163
|
+
|
|
164
|
+
await writeConfig({
|
|
165
|
+
sources: [{ path: '/new/path', depth: 1 }],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
169
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
170
|
+
expect.any(String),
|
|
171
|
+
expect.stringContaining('/new/path'),
|
|
172
|
+
'utf-8',
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('preserves existing fields in the registry file', async () => {
|
|
177
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
178
|
+
mockReadFile.mockResolvedValue(
|
|
179
|
+
JSON.stringify({ existingField: 'preserved', sources: [] }),
|
|
180
|
+
);
|
|
181
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
182
|
+
|
|
183
|
+
await writeConfig({
|
|
184
|
+
sources: [{ path: '/updated', depth: 2 }],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const writtenContent = JSON.parse(
|
|
188
|
+
(mockWriteFile.mock.calls[0][1] as string).trim(),
|
|
189
|
+
);
|
|
190
|
+
expect(writtenContent.existingField).toBe('preserved');
|
|
191
|
+
expect(writtenContent.sources[0].path).toBe('/updated');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('writes pollInterval when provided', async () => {
|
|
195
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
196
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
197
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
198
|
+
|
|
199
|
+
await writeConfig({
|
|
200
|
+
sources: [],
|
|
201
|
+
pollInterval: 5000,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const writtenContent = JSON.parse(
|
|
205
|
+
(mockWriteFile.mock.calls[0][1] as string).trim(),
|
|
206
|
+
);
|
|
207
|
+
expect(writtenContent.pollInterval).toBe(5000);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|