DevToolbar.test.tsx426 lines · main
1import { act, render, screen, waitFor } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
5declare global {
6 interface Window {
7 devTelemetry?: () => void
8 }
9}
10
11// Mock common package
12vi.mock('common', async () => {
13 return {
14 useParams: () => ({ ref: 'default' }),
15 useFeatureFlags: () => ({
16 posthog: {},
17 configcat: {},
18 }),
19 posthogClient: {
20 subscribeToEvents: vi.fn(() => () => {}),
21 },
22 ensurePlatformSuffix: (url: string) => url,
23 }
24})
25
26const originalEnv = process.env.NEXT_PUBLIC_ENVIRONMENT
27
28/**
29 * Helper to render the full component tree as used in production.
30 * The Provider sets up window.devTelemetry and manages state.
31 * The Trigger shows the activity icon in the header.
32 * The Toolbar is the actual panel/sheet.
33 */
34async function renderFullToolbar() {
35 const { DevToolbarProvider } = await import('./DevToolbarContext')
36 const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
37 const { DevToolbar } = await import('./DevToolbar')
38 const { TooltipProvider } = await import('ui')
39
40 return render(
41 <TooltipProvider>
42 <DevToolbarProvider apiUrl="http://localhost:3000">
43 <DevToolbarTrigger />
44 <DevToolbar />
45 </DevToolbarProvider>
46 </TooltipProvider>
47 )
48}
49
50describe('DevToolbar', () => {
51 beforeEach(() => {
52 const store = new Map<string, string>()
53 const localStorageMock = {
54 getItem: (key: string) => store.get(key) ?? null,
55 setItem: (key: string, value: string) => {
56 store.set(key, value)
57 },
58 removeItem: (key: string) => {
59 store.delete(key)
60 },
61 clear: () => {
62 store.clear()
63 },
64 key: (index: number) => Array.from(store.keys())[index] ?? null,
65 get length() {
66 return store.size
67 },
68 }
69
70 Object.defineProperty(globalThis, 'localStorage', {
71 value: localStorageMock,
72 writable: true,
73 })
74
75 localStorage.clear()
76 delete window.devTelemetry
77 vi.spyOn(console, 'warn').mockImplementation(() => {})
78 vi.spyOn(console, 'error').mockImplementation(() => {})
79 })
80
81 afterEach(() => {
82 if (originalEnv === undefined) {
83 delete process.env.NEXT_PUBLIC_ENVIRONMENT
84 } else {
85 process.env.NEXT_PUBLIC_ENVIRONMENT = originalEnv
86 }
87 vi.resetModules()
88 vi.restoreAllMocks()
89 })
90
91 describe('when not in local development', () => {
92 beforeEach(() => {
93 process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod'
94 })
95
96 it('returns null and does not render anything', async () => {
97 vi.resetModules()
98 const { container } = await renderFullToolbar()
99
100 // Neither trigger nor toolbar should render in production
101 expect(container.querySelector('button')).toBeNull()
102 })
103
104 it('does not register window.devTelemetry', async () => {
105 vi.resetModules()
106 await renderFullToolbar()
107
108 expect(window.devTelemetry).toBeUndefined()
109 })
110 })
111
112 describe('when in local development but not enabled', () => {
113 beforeEach(() => {
114 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
115 })
116
117 it('does not render trigger when toolbar is not enabled', async () => {
118 vi.resetModules()
119 const { container } = await renderFullToolbar()
120
121 // Trigger should not render when not enabled
122 expect(container.querySelector('button')).toBeNull()
123 })
124
125 it('registers window.devTelemetry function', async () => {
126 vi.resetModules()
127 await renderFullToolbar()
128
129 expect(window.devTelemetry).toBeDefined()
130 expect(typeof window.devTelemetry).toBe('function')
131 })
132 })
133
134 describe('when in local development and enabled', () => {
135 beforeEach(() => {
136 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
137 localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
138 })
139
140 it('renders the trigger button when enabled via localStorage', async () => {
141 vi.resetModules()
142 await renderFullToolbar()
143
144 // The trigger should be a button with the Activity icon
145 const triggerButton = screen.getByRole('button')
146 expect(triggerButton).toBeInTheDocument()
147 })
148
149 it('opens the toolbar sheet when trigger is clicked', async () => {
150 vi.resetModules()
151 const user = userEvent.setup()
152 await renderFullToolbar()
153
154 const triggerButton = screen.getByRole('button')
155 await user.click(triggerButton)
156
157 // Sheet should open with the title
158 await waitFor(() => {
159 expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
160 })
161 })
162
163 it('shows Events and Flags tabs in the toolbar', async () => {
164 vi.resetModules()
165 const user = userEvent.setup()
166 await renderFullToolbar()
167
168 const triggerButton = screen.getByRole('button')
169 await user.click(triggerButton)
170
171 await waitFor(() => {
172 expect(screen.getByRole('tab', { name: /Events/i })).toBeInTheDocument()
173 expect(screen.getByRole('tab', { name: /Flags/i })).toBeInTheDocument()
174 })
175 })
176 })
177
178 describe('when in staging environment and enabled', () => {
179 beforeEach(() => {
180 process.env.NEXT_PUBLIC_ENVIRONMENT = 'staging'
181 localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
182 })
183
184 it('renders the toolbar trigger', async () => {
185 vi.resetModules()
186 await renderFullToolbar()
187 const triggerButton = screen.getByRole('button')
188 expect(triggerButton).toBeInTheDocument()
189 })
190
191 it('shows server events notice in events tab', async () => {
192 vi.resetModules()
193 const user = userEvent.setup()
194 await renderFullToolbar()
195
196 const triggerButton = screen.getByRole('button')
197 await user.click(triggerButton)
198
199 await waitFor(() => {
200 expect(
201 screen.getByText(
202 'Server-side events are only visible when using the toolbar in local development'
203 )
204 ).toBeInTheDocument()
205 })
206 })
207
208 it('does not connect to SSE in staging', async () => {
209 const EventSourceSpy = vi.fn()
210 vi.stubGlobal('EventSource', EventSourceSpy)
211
212 vi.resetModules()
213 await renderFullToolbar()
214
215 expect(EventSourceSpy).not.toHaveBeenCalled()
216
217 vi.unstubAllGlobals()
218 })
219 })
220
221 describe('window.devTelemetry function', () => {
222 beforeEach(() => {
223 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
224 })
225
226 it('enables toolbar when called', async () => {
227 vi.resetModules()
228 const { rerender } = await renderFullToolbar()
229
230 // Trigger should not be visible initially
231 expect(screen.queryByRole('button')).not.toBeInTheDocument()
232
233 // Call devTelemetry to enable
234 act(() => {
235 window.devTelemetry?.()
236 })
237
238 // Re-import and rerender to pick up state change
239 vi.resetModules()
240 const { DevToolbarProvider } = await import('./DevToolbarContext')
241 const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
242 const { DevToolbar } = await import('./DevToolbar')
243 const { TooltipProvider } = await import('ui')
244
245 rerender(
246 <TooltipProvider>
247 <DevToolbarProvider apiUrl="http://localhost:3000">
248 <DevToolbarTrigger />
249 <DevToolbar />
250 </DevToolbarProvider>
251 </TooltipProvider>
252 )
253
254 expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true')
255 })
256 })
257
258 describe('cleanup', () => {
259 it('removes window.devTelemetry on unmount', async () => {
260 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
261
262 vi.resetModules()
263 const result = await renderFullToolbar()
264
265 expect(window.devTelemetry).toBeDefined()
266
267 result.unmount()
268
269 expect(window.devTelemetry).toBeUndefined()
270 })
271 })
272
273 describe('EventCard keyboard accessibility', () => {
274 beforeEach(() => {
275 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
276 localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
277 })
278
279 it('toolbar renders correctly with empty events state', async () => {
280 vi.resetModules()
281
282 const user = userEvent.setup()
283 await renderFullToolbar()
284
285 const triggerButton = screen.getByRole('button')
286 await user.click(triggerButton)
287
288 await waitFor(() => {
289 expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
290 })
291
292 // Events tab should be active by default and show empty state
293 expect(screen.getByText(/No events yet/i)).toBeInTheDocument()
294 })
295 })
296
297 describe('Flag override UI', () => {
298 beforeEach(() => {
299 process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
300 localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
301 })
302
303 it('shows PostHog and ConfigCat sub-tabs in Flags tab', async () => {
304 vi.resetModules()
305 const user = userEvent.setup()
306 await renderFullToolbar()
307
308 const triggerButton = screen.getByRole('button')
309 await user.click(triggerButton)
310
311 // Switch to Flags tab
312 await waitFor(() => {
313 expect(screen.getByRole('tab', { name: /Flags/i })).toBeInTheDocument()
314 })
315
316 const flagsTab = screen.getByRole('tab', { name: /Flags/i })
317 await user.click(flagsTab)
318
319 await waitFor(() => {
320 expect(screen.getByRole('button', { name: /PostHog/i })).toBeInTheDocument()
321 expect(screen.getByRole('button', { name: /ConfigCat/i })).toBeInTheDocument()
322 })
323 })
324 })
325})
326
327describe('DevToolbar utils', () => {
328 describe('safeJsonParse', () => {
329 it('logs warning for invalid JSON in local environment', async () => {
330 process.env.NODE_ENV = 'development'
331 const consoleSpy = vi.spyOn(console, 'warn')
332
333 vi.resetModules()
334 const { safeJsonParse } = await import('./utils')
335
336 const result = safeJsonParse('invalid json', {}, 'test context')
337
338 expect(result).toEqual({})
339 expect(consoleSpy).toHaveBeenCalledWith(
340 expect.stringContaining('[DevToolbar] Failed to parse JSON'),
341 expect.anything()
342 )
343 })
344
345 it('returns fallback for undefined input', async () => {
346 vi.resetModules()
347 const { safeJsonParse } = await import('./utils')
348
349 const result = safeJsonParse(undefined, { default: true })
350
351 expect(result).toEqual({ default: true })
352 })
353
354 it('parses valid JSON correctly', async () => {
355 vi.resetModules()
356 const { safeJsonParse } = await import('./utils')
357
358 const result = safeJsonParse('{"key": "value"}', {})
359
360 expect(result).toEqual({ key: 'value' })
361 })
362 })
363
364 describe('parseOverrideValue', () => {
365 it('preserves number type when original is number', async () => {
366 vi.resetModules()
367 const { parseOverrideValue } = await import('./utils')
368
369 // String input should be converted to number
370 expect(parseOverrideValue('42', 0)).toBe(42)
371 expect(parseOverrideValue('3.14', 0)).toBe(3.14)
372 })
373
374 it('returns original for invalid number strings', async () => {
375 vi.resetModules()
376 const { parseOverrideValue } = await import('./utils')
377
378 expect(parseOverrideValue('not a number', 5)).toBe(5)
379 })
380
381 it('preserves boolean type', async () => {
382 vi.resetModules()
383 const { parseOverrideValue } = await import('./utils')
384
385 expect(parseOverrideValue(true, false)).toBe(true)
386 expect(parseOverrideValue(false, true)).toBe(false)
387 })
388
389 it('preserves string type', async () => {
390 vi.resetModules()
391 const { parseOverrideValue } = await import('./utils')
392
393 expect(parseOverrideValue('new value', 'original')).toBe('new value')
394 expect(parseOverrideValue(123, 'original')).toBe('123')
395 })
396 })
397
398 describe('valuesAreEqual', () => {
399 it('handles number/string comparison', async () => {
400 vi.resetModules()
401 const { valuesAreEqual } = await import('./utils')
402
403 expect(valuesAreEqual(42, '42')).toBe(true)
404 expect(valuesAreEqual('42', 42)).toBe(true)
405 expect(valuesAreEqual(42, '43')).toBe(false)
406 })
407
408 it('handles boolean comparison strictly', async () => {
409 vi.resetModules()
410 const { valuesAreEqual } = await import('./utils')
411
412 expect(valuesAreEqual(true, true)).toBe(true)
413 expect(valuesAreEqual(false, false)).toBe(true)
414 expect(valuesAreEqual(true, false)).toBe(false)
415 })
416
417 it('handles null values', async () => {
418 vi.resetModules()
419 const { valuesAreEqual } = await import('./utils')
420
421 expect(valuesAreEqual(null, null)).toBe(true)
422 expect(valuesAreEqual(null, 'value')).toBe(false)
423 expect(valuesAreEqual('value', null)).toBe(false)
424 })
425 })
426})