DevToolbar.test.tsx 12 KB

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