@libredb/studio 0.9.7

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.
Files changed (572) hide show
  1. package/.claude/settings.local.json +127 -0
  2. package/.cursorrules +426 -0
  3. package/.devin/wiki.json +143 -0
  4. package/.dockerignore +80 -0
  5. package/.env.example +159 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  8. package/.github/PULL_REQUEST_TEMPLATE.md +57 -0
  9. package/.github/workflows/ci.yml +185 -0
  10. package/.github/workflows/codeql.yml +57 -0
  11. package/.github/workflows/docker-build-push.yml +118 -0
  12. package/.github/workflows/helm-release.yml +113 -0
  13. package/CLAUDE.md +265 -0
  14. package/CODE_OF_CONDUCT.md +124 -0
  15. package/CONTRIBUTING.md +154 -0
  16. package/Dockerfile +73 -0
  17. package/LICENSE +21 -0
  18. package/README.md +614 -0
  19. package/SECURITY.md +107 -0
  20. package/artifacthub-repo.yml +4 -0
  21. package/bun.lock +1714 -0
  22. package/bunfig.toml +3 -0
  23. package/charts/libredb-studio/.helmignore +11 -0
  24. package/charts/libredb-studio/Chart.lock +6 -0
  25. package/charts/libredb-studio/Chart.yaml +50 -0
  26. package/charts/libredb-studio/README.md +206 -0
  27. package/charts/libredb-studio/templates/NOTES.txt +59 -0
  28. package/charts/libredb-studio/templates/_helpers.tpl +135 -0
  29. package/charts/libredb-studio/templates/configmap.yaml +37 -0
  30. package/charts/libredb-studio/templates/deployment.yaml +184 -0
  31. package/charts/libredb-studio/templates/hpa.yaml +32 -0
  32. package/charts/libredb-studio/templates/ingress.yaml +41 -0
  33. package/charts/libredb-studio/templates/networkpolicy.yaml +50 -0
  34. package/charts/libredb-studio/templates/pdb.yaml +18 -0
  35. package/charts/libredb-studio/templates/pvc.yaml +23 -0
  36. package/charts/libredb-studio/templates/secret.yaml +30 -0
  37. package/charts/libredb-studio/templates/seed-configmap.yaml +11 -0
  38. package/charts/libredb-studio/templates/service.yaml +22 -0
  39. package/charts/libredb-studio/templates/serviceaccount.yaml +13 -0
  40. package/charts/libredb-studio/values.schema.json +246 -0
  41. package/charts/libredb-studio/values.yaml +286 -0
  42. package/components.json +22 -0
  43. package/conductor/code_styleguides/typescript.md +43 -0
  44. package/conductor/product-guidelines.md +43 -0
  45. package/conductor/product.md +3 -0
  46. package/conductor/setup_state.json +1 -0
  47. package/conductor/tech-stack.md +39 -0
  48. package/conductor/tracks/enhance_postgres_monitoring_20251227/metadata.json +8 -0
  49. package/conductor/tracks/enhance_postgres_monitoring_20251227/plan.md +44 -0
  50. package/conductor/tracks/enhance_postgres_monitoring_20251227/spec.md +31 -0
  51. package/conductor/tracks.md +8 -0
  52. package/conductor/workflow.md +333 -0
  53. package/database-compose.yml +55 -0
  54. package/docker/postgres-init/01-extensions.sql +10 -0
  55. package/docker/postgres-init/02-sample-data.sql +585 -0
  56. package/docker/postgres.yml +68 -0
  57. package/docker-compose.yml +38 -0
  58. package/docs/AI_PLAN.md +74 -0
  59. package/docs/API_DOCS.md +875 -0
  60. package/docs/ARCHITECTURE.md +218 -0
  61. package/docs/DATABASE_PROVIDERS.md +358 -0
  62. package/docs/FEATURES.md +116 -0
  63. package/docs/HELM_CHART.md +252 -0
  64. package/docs/LOGIN_PAGE.md +178 -0
  65. package/docs/MONACO_EDITOR_PERFORMANCE.md +315 -0
  66. package/docs/OIDC_ARCH.md +681 -0
  67. package/docs/OIDC_SETUP.md +322 -0
  68. package/docs/POSTGRES_METRICS.md +516 -0
  69. package/docs/QUERY_OPTIMIZATION.md +370 -0
  70. package/docs/SEED_CONNECTIONS.md +468 -0
  71. package/docs/SQL_ALIAS_COMPLETION.md +190 -0
  72. package/docs/STORAGE_ARCHITECTURE.md +565 -0
  73. package/docs/STORAGE_QUICK_SETUP.md +419 -0
  74. package/docs/TECHNICAL_PLAN.md +36 -0
  75. package/docs/THEMING.md +345 -0
  76. package/docs/adding-a-new-database-provider.md +642 -0
  77. package/docs/backlogs/000-PLATFORM_DATA_SYNC_DATABASE.md +360 -0
  78. package/docs/backlogs/001-INLINE_DATA_EDITING.md +118 -0
  79. package/docs/backlogs/002-DATA_IMPORT.md +215 -0
  80. package/docs/backlogs/003-QUERY_TIME_MACHINE.md +183 -0
  81. package/docs/backlogs/004-AI_DATA_STORYTELLER.md +292 -0
  82. package/docs/backlogs/005-QUERY_PLAYGROUND.md +352 -0
  83. package/docs/backlogs/006-DATA_MASKING.md +418 -0
  84. package/docs/enterprise-features.md +718 -0
  85. package/docs/kubernetes-helm-chart-artifacthub-plan.md +803 -0
  86. package/docs/medium-koyeb-article-en.md +215 -0
  87. package/docs/plans/test-plans.md +445 -0
  88. package/docs/releases/RELEASE.V0.3.0.md +22 -0
  89. package/docs/releases/RELEASE.V0.4.0.md +154 -0
  90. package/docs/releases/RELEASE.V0.5.0.md +252 -0
  91. package/docs/releases/RELEASE_v0.5.6.md +145 -0
  92. package/docs/releases/RELEASE_v0.6.1.md +303 -0
  93. package/docs/releases/RELEASE_v0.6.7.md +292 -0
  94. package/docs/releases/RELEASE_v0.7.0.md +332 -0
  95. package/docs/releases/RELEASE_v0.8.0.md +521 -0
  96. package/docs/sampledb/titanic.sql +1379 -0
  97. package/docs/superpowers/plans/2026-03-25-seed-connections.md +1362 -0
  98. package/docs/superpowers/specs/2026-03-25-seed-connections-design.md +590 -0
  99. package/e2e/admin-dashboard.spec.ts +64 -0
  100. package/e2e/connection-management.spec.ts +58 -0
  101. package/e2e/export.spec.ts +34 -0
  102. package/e2e/login.spec.ts +85 -0
  103. package/e2e/query-execution.spec.ts +35 -0
  104. package/e2e/tab-management.spec.ts +64 -0
  105. package/eslint.config.mjs +28 -0
  106. package/fly.toml +43 -0
  107. package/next.config.ts +32 -0
  108. package/package.json +130 -0
  109. package/playwright.config.ts +34 -0
  110. package/postcss.config.mjs +7 -0
  111. package/public/favicon-32x32.png +0 -0
  112. package/public/favicon.ico +0 -0
  113. package/public/file.svg +1 -0
  114. package/public/globe.svg +1 -0
  115. package/public/logo.svg +32 -0
  116. package/public/next.svg +1 -0
  117. package/public/screenshots/code-generator.png +0 -0
  118. package/public/screenshots/connection-modal.png +0 -0
  119. package/public/screenshots/data-profiler.png +0 -0
  120. package/public/screenshots/erd-diagram.png +0 -0
  121. package/public/screenshots/hero-editor.png +0 -0
  122. package/public/screenshots/nl2sql.png +0 -0
  123. package/public/vercel.svg +1 -0
  124. package/public/window.svg +1 -0
  125. package/render.yaml +58 -0
  126. package/scripts/merge-lcov.mjs +239 -0
  127. package/sonar-project.properties +16 -0
  128. package/src/app/admin/error.tsx +46 -0
  129. package/src/app/admin/page.tsx +10 -0
  130. package/src/app/api/admin/audit/route.ts +52 -0
  131. package/src/app/api/admin/fleet-health/route.ts +81 -0
  132. package/src/app/api/ai/autopilot/route.ts +105 -0
  133. package/src/app/api/ai/chat/route.ts +132 -0
  134. package/src/app/api/ai/describe-schema/route.ts +52 -0
  135. package/src/app/api/ai/explain/route.ts +86 -0
  136. package/src/app/api/ai/impact/route.ts +97 -0
  137. package/src/app/api/ai/index-advisor/route.ts +98 -0
  138. package/src/app/api/ai/nl2sql/route.ts +87 -0
  139. package/src/app/api/ai/query-safety/route.ts +87 -0
  140. package/src/app/api/auth/login/route.ts +62 -0
  141. package/src/app/api/auth/logout/route.ts +25 -0
  142. package/src/app/api/auth/me/route.ts +10 -0
  143. package/src/app/api/auth/oidc/callback/route.ts +82 -0
  144. package/src/app/api/auth/oidc/login/route.ts +43 -0
  145. package/src/app/api/connections/managed/route.ts +35 -0
  146. package/src/app/api/db/cancel/route.ts +42 -0
  147. package/src/app/api/db/disconnect/route.ts +28 -0
  148. package/src/app/api/db/health/route.ts +49 -0
  149. package/src/app/api/db/maintenance/route.ts +72 -0
  150. package/src/app/api/db/monitoring/route.ts +62 -0
  151. package/src/app/api/db/multi-query/route.ts +116 -0
  152. package/src/app/api/db/pool-stats/route.ts +37 -0
  153. package/src/app/api/db/profile/route.ts +144 -0
  154. package/src/app/api/db/provider-meta/route.ts +49 -0
  155. package/src/app/api/db/query/route.ts +50 -0
  156. package/src/app/api/db/schema/route.ts +47 -0
  157. package/src/app/api/db/schema-snapshot/route.ts +42 -0
  158. package/src/app/api/db/test-connection/route.ts +55 -0
  159. package/src/app/api/db/transaction/route.ts +111 -0
  160. package/src/app/api/storage/[collection]/route.ts +67 -0
  161. package/src/app/api/storage/config/route.ts +17 -0
  162. package/src/app/api/storage/migrate/route.ts +45 -0
  163. package/src/app/api/storage/route.ts +32 -0
  164. package/src/app/error.tsx +49 -0
  165. package/src/app/global-error.tsx +55 -0
  166. package/src/app/globals.css +146 -0
  167. package/src/app/icon.svg +42 -0
  168. package/src/app/layout.tsx +34 -0
  169. package/src/app/login/login-form.tsx +301 -0
  170. package/src/app/login/page.tsx +11 -0
  171. package/src/app/monitoring/page.tsx +8 -0
  172. package/src/app/not-found.tsx +29 -0
  173. package/src/app/page.tsx +5 -0
  174. package/src/components/AIAutopilotPanel.tsx +238 -0
  175. package/src/components/CodeGenerator.tsx +271 -0
  176. package/src/components/CommandPalette.tsx +227 -0
  177. package/src/components/ConnectionModal.tsx +759 -0
  178. package/src/components/CreateTableModal.tsx +281 -0
  179. package/src/components/DataCharts.tsx +962 -0
  180. package/src/components/DataImportModal.tsx +582 -0
  181. package/src/components/DataProfiler.tsx +335 -0
  182. package/src/components/DatabaseDocs.tsx +251 -0
  183. package/src/components/MaskingSettings.tsx +414 -0
  184. package/src/components/MobileNav.tsx +50 -0
  185. package/src/components/NL2SQLPanel.tsx +281 -0
  186. package/src/components/PivotTable.tsx +257 -0
  187. package/src/components/QueryEditor.tsx +760 -0
  188. package/src/components/QueryHistory.tsx +344 -0
  189. package/src/components/QuerySafetyDialog.tsx +290 -0
  190. package/src/components/ResultsGrid.tsx +644 -0
  191. package/src/components/SaveQueryModal.tsx +104 -0
  192. package/src/components/SavedQueries.tsx +128 -0
  193. package/src/components/SchemaDiagram.tsx +473 -0
  194. package/src/components/SchemaDiff.tsx +473 -0
  195. package/src/components/SnapshotTimeline.tsx +116 -0
  196. package/src/components/Studio.tsx +639 -0
  197. package/src/components/TestDataGenerator.tsx +261 -0
  198. package/src/components/VisualExplain.tsx +820 -0
  199. package/src/components/admin/AdminDashboard.tsx +163 -0
  200. package/src/components/admin/tabs/AuditTab.tsx +531 -0
  201. package/src/components/admin/tabs/MonitoringEmbed.tsx +11 -0
  202. package/src/components/admin/tabs/OperationsTab.tsx +646 -0
  203. package/src/components/admin/tabs/OverviewTab.tsx +1328 -0
  204. package/src/components/admin/tabs/SecurityTab.tsx +284 -0
  205. package/src/components/community-section.tsx +92 -0
  206. package/src/components/icons/db-icons.tsx +84 -0
  207. package/src/components/libredb-logo.tsx +61 -0
  208. package/src/components/monitoring/MonitoringDashboard.tsx +345 -0
  209. package/src/components/monitoring/tabs/MetricChart.tsx +82 -0
  210. package/src/components/monitoring/tabs/OverviewTab.tsx +263 -0
  211. package/src/components/monitoring/tabs/PerformanceTab.tsx +254 -0
  212. package/src/components/monitoring/tabs/PoolTab.tsx +174 -0
  213. package/src/components/monitoring/tabs/QueriesTab.tsx +287 -0
  214. package/src/components/monitoring/tabs/SessionsTab.tsx +316 -0
  215. package/src/components/monitoring/tabs/StorageTab.tsx +335 -0
  216. package/src/components/monitoring/tabs/TablesTab.tsx +300 -0
  217. package/src/components/results-grid/ResultCard.tsx +111 -0
  218. package/src/components/results-grid/RowDetailSheet.tsx +178 -0
  219. package/src/components/results-grid/StatsBar.tsx +201 -0
  220. package/src/components/results-grid/index.ts +1 -0
  221. package/src/components/results-grid/utils.ts +23 -0
  222. package/src/components/schema-explorer/ColumnList.tsx +53 -0
  223. package/src/components/schema-explorer/SchemaExplorer.tsx +182 -0
  224. package/src/components/schema-explorer/TableItem.tsx +210 -0
  225. package/src/components/schema-explorer/index.ts +1 -0
  226. package/src/components/sidebar/ConnectionItem.tsx +105 -0
  227. package/src/components/sidebar/ConnectionsList.tsx +62 -0
  228. package/src/components/sidebar/Sidebar.tsx +130 -0
  229. package/src/components/sidebar/index.ts +2 -0
  230. package/src/components/studio/BottomPanel.tsx +286 -0
  231. package/src/components/studio/QueryToolbar.tsx +180 -0
  232. package/src/components/studio/StudioDesktopHeader.tsx +114 -0
  233. package/src/components/studio/StudioMobileHeader.tsx +340 -0
  234. package/src/components/studio/StudioTabBar.tsx +82 -0
  235. package/src/components/studio/index.ts +5 -0
  236. package/src/components/ui/accordion.tsx +66 -0
  237. package/src/components/ui/alert-dialog.tsx +157 -0
  238. package/src/components/ui/alert.tsx +66 -0
  239. package/src/components/ui/aspect-ratio.tsx +11 -0
  240. package/src/components/ui/avatar.tsx +53 -0
  241. package/src/components/ui/badge.tsx +46 -0
  242. package/src/components/ui/breadcrumb.tsx +109 -0
  243. package/src/components/ui/button-group.tsx +83 -0
  244. package/src/components/ui/button.tsx +60 -0
  245. package/src/components/ui/calendar.tsx +216 -0
  246. package/src/components/ui/card.tsx +92 -0
  247. package/src/components/ui/carousel.tsx +241 -0
  248. package/src/components/ui/chart.tsx +357 -0
  249. package/src/components/ui/checkbox.tsx +32 -0
  250. package/src/components/ui/collapsible.tsx +33 -0
  251. package/src/components/ui/command.tsx +184 -0
  252. package/src/components/ui/context-menu.tsx +252 -0
  253. package/src/components/ui/dialog.tsx +143 -0
  254. package/src/components/ui/drawer.tsx +135 -0
  255. package/src/components/ui/dropdown-menu.tsx +257 -0
  256. package/src/components/ui/empty.tsx +104 -0
  257. package/src/components/ui/field.tsx +248 -0
  258. package/src/components/ui/form.tsx +167 -0
  259. package/src/components/ui/hover-card.tsx +44 -0
  260. package/src/components/ui/input-group.tsx +170 -0
  261. package/src/components/ui/input-otp.tsx +77 -0
  262. package/src/components/ui/input.tsx +21 -0
  263. package/src/components/ui/item.tsx +193 -0
  264. package/src/components/ui/kbd.tsx +28 -0
  265. package/src/components/ui/label.tsx +24 -0
  266. package/src/components/ui/menubar.tsx +276 -0
  267. package/src/components/ui/navigation-menu.tsx +168 -0
  268. package/src/components/ui/pagination.tsx +127 -0
  269. package/src/components/ui/popover.tsx +48 -0
  270. package/src/components/ui/progress.tsx +31 -0
  271. package/src/components/ui/radio-group.tsx +45 -0
  272. package/src/components/ui/resizable.tsx +56 -0
  273. package/src/components/ui/scroll-area.tsx +58 -0
  274. package/src/components/ui/select.tsx +187 -0
  275. package/src/components/ui/separator.tsx +28 -0
  276. package/src/components/ui/sheet.tsx +139 -0
  277. package/src/components/ui/sidebar.tsx +726 -0
  278. package/src/components/ui/skeleton.tsx +13 -0
  279. package/src/components/ui/slider.tsx +63 -0
  280. package/src/components/ui/sonner.tsx +40 -0
  281. package/src/components/ui/spinner.tsx +16 -0
  282. package/src/components/ui/switch.tsx +31 -0
  283. package/src/components/ui/table.tsx +116 -0
  284. package/src/components/ui/tabs.tsx +66 -0
  285. package/src/components/ui/textarea.tsx +18 -0
  286. package/src/components/ui/toggle-group.tsx +83 -0
  287. package/src/components/ui/toggle.tsx +47 -0
  288. package/src/components/ui/tooltip.tsx +61 -0
  289. package/src/exports/components.ts +15 -0
  290. package/src/exports/index.ts +4 -0
  291. package/src/exports/providers.ts +4 -0
  292. package/src/exports/types.ts +26 -0
  293. package/src/hooks/use-ai-chat.ts +182 -0
  294. package/src/hooks/use-all-connections.ts +66 -0
  295. package/src/hooks/use-api-call.ts +71 -0
  296. package/src/hooks/use-auth.ts +51 -0
  297. package/src/hooks/use-connection-form.ts +349 -0
  298. package/src/hooks/use-connection-manager.ts +169 -0
  299. package/src/hooks/use-connection-payload.ts +15 -0
  300. package/src/hooks/use-inline-editing.ts +109 -0
  301. package/src/hooks/use-mobile.ts +20 -0
  302. package/src/hooks/use-monitoring-data.ts +270 -0
  303. package/src/hooks/use-provider-metadata.ts +62 -0
  304. package/src/hooks/use-query-execution.ts +478 -0
  305. package/src/hooks/use-storage-sync.ts +259 -0
  306. package/src/hooks/use-tab-manager.ts +231 -0
  307. package/src/hooks/use-toast.ts +20 -0
  308. package/src/hooks/use-transaction-control.ts +64 -0
  309. package/src/lib/api/error-codes.ts +30 -0
  310. package/src/lib/api/errors.ts +236 -0
  311. package/src/lib/api/with-error-handler.ts +41 -0
  312. package/src/lib/audit.ts +105 -0
  313. package/src/lib/auth.ts +87 -0
  314. package/src/lib/connection-string-parser.ts +172 -0
  315. package/src/lib/data-masking.ts +385 -0
  316. package/src/lib/db/base-provider.ts +325 -0
  317. package/src/lib/db/errors.ts +317 -0
  318. package/src/lib/db/factory.ts +324 -0
  319. package/src/lib/db/index.ts +123 -0
  320. package/src/lib/db/providers/document/index.ts +6 -0
  321. package/src/lib/db/providers/document/mongodb.ts +992 -0
  322. package/src/lib/db/providers/keyvalue/redis.ts +554 -0
  323. package/src/lib/db/providers/sql/index.ts +11 -0
  324. package/src/lib/db/providers/sql/mssql.ts +1065 -0
  325. package/src/lib/db/providers/sql/mysql.ts +978 -0
  326. package/src/lib/db/providers/sql/oracle.ts +1044 -0
  327. package/src/lib/db/providers/sql/postgres.ts +1179 -0
  328. package/src/lib/db/providers/sql/sql-base.ts +174 -0
  329. package/src/lib/db/providers/sql/sqlite.ts +721 -0
  330. package/src/lib/db/types.ts +437 -0
  331. package/src/lib/db/utils/pool-manager.ts +287 -0
  332. package/src/lib/db/utils/query-limiter.ts +239 -0
  333. package/src/lib/db-ui-config.ts +86 -0
  334. package/src/lib/editor/mongodb-completions.ts +172 -0
  335. package/src/lib/editor/sql-completions.ts +280 -0
  336. package/src/lib/llm/base-provider.ts +117 -0
  337. package/src/lib/llm/factory.ts +102 -0
  338. package/src/lib/llm/index.ts +90 -0
  339. package/src/lib/llm/providers/custom.ts +181 -0
  340. package/src/lib/llm/providers/gemini.ts +126 -0
  341. package/src/lib/llm/providers/ollama.ts +154 -0
  342. package/src/lib/llm/providers/openai.ts +146 -0
  343. package/src/lib/llm/types.ts +173 -0
  344. package/src/lib/llm/utils/config.ts +187 -0
  345. package/src/lib/llm/utils/retry.ts +119 -0
  346. package/src/lib/llm/utils/streaming.ts +202 -0
  347. package/src/lib/logger.ts +127 -0
  348. package/src/lib/monitoring-thresholds.ts +44 -0
  349. package/src/lib/oidc.ts +262 -0
  350. package/src/lib/query-generators.ts +61 -0
  351. package/src/lib/schema-diff/diff-engine.ts +273 -0
  352. package/src/lib/schema-diff/migration-generator.ts +208 -0
  353. package/src/lib/schema-diff/types.ts +55 -0
  354. package/src/lib/seed/config-loader.ts +79 -0
  355. package/src/lib/seed/connection-filter.ts +49 -0
  356. package/src/lib/seed/credential-resolver.ts +62 -0
  357. package/src/lib/seed/index.ts +40 -0
  358. package/src/lib/seed/resolve-connection.ts +57 -0
  359. package/src/lib/seed/types.ts +69 -0
  360. package/src/lib/sql/alias-extractor.ts +267 -0
  361. package/src/lib/sql/index.ts +8 -0
  362. package/src/lib/sql/statement-splitter.ts +167 -0
  363. package/src/lib/sql/types.ts +40 -0
  364. package/src/lib/ssh/tunnel.ts +142 -0
  365. package/src/lib/storage/factory.ts +84 -0
  366. package/src/lib/storage/index.ts +14 -0
  367. package/src/lib/storage/local-storage.ts +99 -0
  368. package/src/lib/storage/providers/postgres.ts +225 -0
  369. package/src/lib/storage/providers/sqlite.ts +153 -0
  370. package/src/lib/storage/storage-facade.ts +272 -0
  371. package/src/lib/storage/types.ts +75 -0
  372. package/src/lib/time-series-buffer.ts +58 -0
  373. package/src/lib/types.ts +173 -0
  374. package/src/lib/utils.ts +6 -0
  375. package/src/proxy.ts +104 -0
  376. package/src/types/db-drivers.d.ts +23 -0
  377. package/src/types/html2canvas.d.ts +9 -0
  378. package/tests/api/admin/audit.test.ts +178 -0
  379. package/tests/api/admin/fleet-health.test.ts +183 -0
  380. package/tests/api/ai/autopilot.test.ts +174 -0
  381. package/tests/api/ai/chat.test.ts +250 -0
  382. package/tests/api/ai/describe-schema.test.ts +266 -0
  383. package/tests/api/ai/explain.test.ts +199 -0
  384. package/tests/api/ai/impact.test.ts +168 -0
  385. package/tests/api/ai/index-advisor.test.ts +171 -0
  386. package/tests/api/ai/nl2sql.test.ts +202 -0
  387. package/tests/api/ai/query-safety.test.ts +196 -0
  388. package/tests/api/auth/login.test.ts +170 -0
  389. package/tests/api/auth/logout.test.ts +140 -0
  390. package/tests/api/auth/me.test.ts +73 -0
  391. package/tests/api/auth/oidc-callback.test.ts +215 -0
  392. package/tests/api/auth/oidc-login.test.ts +127 -0
  393. package/tests/api/db/cancel.test.ts +198 -0
  394. package/tests/api/db/disconnect.test.ts +124 -0
  395. package/tests/api/db/health.test.ts +222 -0
  396. package/tests/api/db/maintenance.test.ts +263 -0
  397. package/tests/api/db/monitoring.test.ts +221 -0
  398. package/tests/api/db/multi-query.test.ts +316 -0
  399. package/tests/api/db/pool-stats.test.ts +135 -0
  400. package/tests/api/db/profile.test.ts +330 -0
  401. package/tests/api/db/provider-meta.test.ts +193 -0
  402. package/tests/api/db/query.test.ts +314 -0
  403. package/tests/api/db/schema-snapshot.test.ts +170 -0
  404. package/tests/api/db/schema.test.ts +191 -0
  405. package/tests/api/db/test-connection.test.ts +185 -0
  406. package/tests/api/db/transaction.test.ts +314 -0
  407. package/tests/api/proxy.test.ts +191 -0
  408. package/tests/api/seed/managed-route.test.ts +113 -0
  409. package/tests/api/storage/config.test.ts +42 -0
  410. package/tests/api/storage/storage-routes.test.ts +309 -0
  411. package/tests/components/AIAutopilotPanel.test.tsx +756 -0
  412. package/tests/components/AdminPage.test.tsx +33 -0
  413. package/tests/components/CodeGenerator.test.tsx +182 -0
  414. package/tests/components/CommandPalette.test.tsx +428 -0
  415. package/tests/components/CommunitySection.test.tsx +91 -0
  416. package/tests/components/ConnectionModal.mobile.test.tsx +284 -0
  417. package/tests/components/ConnectionModal.test.tsx +570 -0
  418. package/tests/components/CreateTableModal.test.tsx +383 -0
  419. package/tests/components/DataCharts.test.tsx +739 -0
  420. package/tests/components/DataImportModal.test.tsx +751 -0
  421. package/tests/components/DataProfiler.test.tsx +589 -0
  422. package/tests/components/DatabaseDocs.test.tsx +353 -0
  423. package/tests/components/LoginPage.test.tsx +163 -0
  424. package/tests/components/LoginPageOIDC.test.tsx +92 -0
  425. package/tests/components/MaskingSettings.test.tsx +498 -0
  426. package/tests/components/MobileNav.test.tsx +30 -0
  427. package/tests/components/MonitoringPage.test.tsx +32 -0
  428. package/tests/components/NL2SQLPanel.test.tsx +621 -0
  429. package/tests/components/Page.test.tsx +33 -0
  430. package/tests/components/PivotTable.test.tsx +350 -0
  431. package/tests/components/QueryEditor.test.tsx +1730 -0
  432. package/tests/components/QueryHistory.test.tsx +572 -0
  433. package/tests/components/QuerySafetyDialog.test.tsx +586 -0
  434. package/tests/components/ResultsGrid.test.tsx +804 -0
  435. package/tests/components/RootLayout.test.tsx +83 -0
  436. package/tests/components/SaveQueryModal.test.tsx +25 -0
  437. package/tests/components/SavedQueries.test.tsx +43 -0
  438. package/tests/components/SchemaDiagram.test.tsx +1034 -0
  439. package/tests/components/SchemaDiff.test.tsx +906 -0
  440. package/tests/components/SnapshotTimeline.test.tsx +174 -0
  441. package/tests/components/Studio.test.tsx +1030 -0
  442. package/tests/components/TestDataGenerator.test.tsx +291 -0
  443. package/tests/components/VisualExplain.test.tsx +704 -0
  444. package/tests/components/admin/AdminDashboard.test.tsx +205 -0
  445. package/tests/components/admin/AuditTab.test.tsx +220 -0
  446. package/tests/components/admin/MonitoringEmbed.test.tsx +58 -0
  447. package/tests/components/admin/OperationsTab.test.tsx +975 -0
  448. package/tests/components/admin/OverviewTab.test.tsx +254 -0
  449. package/tests/components/admin/SecurityTab.test.tsx +467 -0
  450. package/tests/components/monitoring/MetricChart.test.tsx +111 -0
  451. package/tests/components/monitoring/MonitoringDashboard.test.tsx +259 -0
  452. package/tests/components/monitoring/OverviewTab.test.tsx +78 -0
  453. package/tests/components/monitoring/PerformanceTab.test.tsx +87 -0
  454. package/tests/components/monitoring/PoolTab.test.tsx +42 -0
  455. package/tests/components/monitoring/QueriesTab.test.tsx +80 -0
  456. package/tests/components/monitoring/SessionsTab.test.tsx +154 -0
  457. package/tests/components/monitoring/StorageTab.test.tsx +127 -0
  458. package/tests/components/monitoring/TablesTab.test.tsx +153 -0
  459. package/tests/components/results-grid/ResultCard.test.tsx +105 -0
  460. package/tests/components/results-grid/RowDetailSheet.test.tsx +308 -0
  461. package/tests/components/results-grid/StatsBar.test.tsx +162 -0
  462. package/tests/components/schema-explorer/ColumnList.test.tsx +151 -0
  463. package/tests/components/schema-explorer/SchemaExplorer.test.tsx +461 -0
  464. package/tests/components/schema-explorer/TableItem.test.tsx +415 -0
  465. package/tests/components/sidebar/ConnectionItem.test.tsx +201 -0
  466. package/tests/components/sidebar/ConnectionsList.test.tsx +176 -0
  467. package/tests/components/sidebar/Sidebar.test.tsx +187 -0
  468. package/tests/components/studio/BottomPanel.test.tsx +383 -0
  469. package/tests/components/studio/QueryToolbar.test.tsx +321 -0
  470. package/tests/components/studio/StudioDesktopHeader.test.tsx +377 -0
  471. package/tests/components/studio/StudioMobileHeader.test.tsx +198 -0
  472. package/tests/components/studio/StudioTabBar.test.tsx +331 -0
  473. package/tests/fixtures/connections.ts +96 -0
  474. package/tests/fixtures/masking-configs.ts +86 -0
  475. package/tests/fixtures/query-results.ts +71 -0
  476. package/tests/fixtures/schemas.ts +64 -0
  477. package/tests/fixtures/seed-connections/invalid-config.yaml +7 -0
  478. package/tests/fixtures/seed-connections/minimal-config.yaml +8 -0
  479. package/tests/fixtures/seed-connections/mixed-credentials.yaml +23 -0
  480. package/tests/fixtures/seed-connections/multi-role-config.yaml +30 -0
  481. package/tests/fixtures/seed-connections/valid-config.json +15 -0
  482. package/tests/fixtures/seed-connections/valid-config.yaml +51 -0
  483. package/tests/helpers/mock-fetch.ts +59 -0
  484. package/tests/helpers/mock-monaco.ts +112 -0
  485. package/tests/helpers/mock-navigation.ts +28 -0
  486. package/tests/helpers/mock-next.ts +80 -0
  487. package/tests/helpers/mock-provider.ts +133 -0
  488. package/tests/helpers/mock-sonner.ts +29 -0
  489. package/tests/helpers/render-with-providers.tsx +19 -0
  490. package/tests/hooks/use-ai-chat.test.ts +600 -0
  491. package/tests/hooks/use-auth.test.ts +371 -0
  492. package/tests/hooks/use-connection-form.test.ts +743 -0
  493. package/tests/hooks/use-connection-manager.test.ts +466 -0
  494. package/tests/hooks/use-inline-editing.test.ts +321 -0
  495. package/tests/hooks/use-mobile.test.ts +177 -0
  496. package/tests/hooks/use-monitoring-data.test.ts +819 -0
  497. package/tests/hooks/use-provider-metadata.test.ts +228 -0
  498. package/tests/hooks/use-query-execution.test.ts +1212 -0
  499. package/tests/hooks/use-tab-manager.test.ts +756 -0
  500. package/tests/hooks/use-toast.test.ts +74 -0
  501. package/tests/hooks/use-transaction-control.test.ts +211 -0
  502. package/tests/integration/db/mongodb-provider.test.ts +698 -0
  503. package/tests/integration/db/mssql-provider.test.ts +840 -0
  504. package/tests/integration/db/mysql-provider.test.ts +872 -0
  505. package/tests/integration/db/oracle-provider.test.ts +843 -0
  506. package/tests/integration/db/postgres-provider.test.ts +1382 -0
  507. package/tests/integration/db/redis-provider.test.ts +526 -0
  508. package/tests/integration/db/sqlite-provider.test.ts +480 -0
  509. package/tests/integration/seed/seed-pipeline.test.ts +102 -0
  510. package/tests/isolated/factory-singleton.test.ts +150 -0
  511. package/tests/isolated/use-storage-sync.test.ts +389 -0
  512. package/tests/run-components.sh +196 -0
  513. package/tests/setup-dom.ts +58 -0
  514. package/tests/setup.ts +40 -0
  515. package/tests/unit/api-errors.test.ts +210 -0
  516. package/tests/unit/code-generator-functions.test.ts +271 -0
  517. package/tests/unit/components/column-list.test.tsx +190 -0
  518. package/tests/unit/components/data-import-modal.test.tsx +441 -0
  519. package/tests/unit/components/studio-mobile-header.test.tsx +327 -0
  520. package/tests/unit/data-charts-functions.test.ts +496 -0
  521. package/tests/unit/data-import-functions.test.ts +320 -0
  522. package/tests/unit/data-import-utils.test.ts +125 -0
  523. package/tests/unit/db/base-provider.test.ts +517 -0
  524. package/tests/unit/db/errors.test.ts +403 -0
  525. package/tests/unit/db/factory.test.ts +436 -0
  526. package/tests/unit/db/pool-manager.test.ts +440 -0
  527. package/tests/unit/db/query-limiter.test.ts +387 -0
  528. package/tests/unit/db/sql-base.test.ts +438 -0
  529. package/tests/unit/lib/api/error-codes.test.ts +39 -0
  530. package/tests/unit/lib/audit.test.ts +326 -0
  531. package/tests/unit/lib/auth.test.ts +146 -0
  532. package/tests/unit/lib/connection-string-parser.test.ts +424 -0
  533. package/tests/unit/lib/data-masking.test.ts +583 -0
  534. package/tests/unit/lib/db-icons.test.tsx +41 -0
  535. package/tests/unit/lib/monitoring-thresholds.test.ts +133 -0
  536. package/tests/unit/lib/oidc.test.ts +509 -0
  537. package/tests/unit/lib/query-generators.test.ts +127 -0
  538. package/tests/unit/lib/storage/factory.test.ts +71 -0
  539. package/tests/unit/lib/storage/local-storage.test.ts +114 -0
  540. package/tests/unit/lib/storage/providers/postgres.test.ts +312 -0
  541. package/tests/unit/lib/storage/providers/sqlite.test.ts +232 -0
  542. package/tests/unit/lib/storage/storage-facade-extended.test.ts +331 -0
  543. package/tests/unit/lib/storage/storage-facade.test.ts +184 -0
  544. package/tests/unit/lib/storage.test.ts +317 -0
  545. package/tests/unit/lib/time-series-buffer.test.ts +212 -0
  546. package/tests/unit/lib/utils.test.ts +24 -0
  547. package/tests/unit/llm/base-provider.test.ts +238 -0
  548. package/tests/unit/llm/config.test.ts +262 -0
  549. package/tests/unit/llm/custom-provider.test.ts +281 -0
  550. package/tests/unit/llm/gemini-provider.test.ts +248 -0
  551. package/tests/unit/llm/llm-factory.test.ts +155 -0
  552. package/tests/unit/llm/ollama-provider.test.ts +288 -0
  553. package/tests/unit/llm/openai-provider.test.ts +324 -0
  554. package/tests/unit/llm/retry.test.ts +180 -0
  555. package/tests/unit/llm/streaming.test.ts +355 -0
  556. package/tests/unit/logger.test.ts +198 -0
  557. package/tests/unit/mongodb-completions.test.ts +516 -0
  558. package/tests/unit/pivot-table-functions.test.ts +76 -0
  559. package/tests/unit/query-cancelled-error.test.ts +81 -0
  560. package/tests/unit/schema-diff/diff-engine.test.ts +367 -0
  561. package/tests/unit/schema-diff/migration-generator.test.ts +513 -0
  562. package/tests/unit/seed/config-loader.test.ts +73 -0
  563. package/tests/unit/seed/connection-filter.test.ts +91 -0
  564. package/tests/unit/seed/credential-resolver.test.ts +85 -0
  565. package/tests/unit/seed/index.test.ts +72 -0
  566. package/tests/unit/seed/resolve-connection.test.ts +74 -0
  567. package/tests/unit/seed/types.test.ts +129 -0
  568. package/tests/unit/sql/alias-extractor.test.ts +444 -0
  569. package/tests/unit/sql/statement-splitter.test.ts +348 -0
  570. package/tests/unit/sql-completions.test.ts +463 -0
  571. package/tests/unit/ssh-tunnel.test.ts +465 -0
  572. package/tsconfig.json +42 -0
@@ -0,0 +1,906 @@
1
+ import '../setup-dom';
2
+ import '../helpers/mock-sonner';
3
+ import '../helpers/mock-navigation';
4
+
5
+ import { mock } from 'bun:test';
6
+ import React from 'react';
7
+
8
+ // ── Mock data ────────────────────────────────────────────────────────────────
9
+
10
+ const mockDiffWithChanges = {
11
+ tables: [
12
+ {
13
+ action: 'added',
14
+ tableName: 'new_table',
15
+ columns: [
16
+ { action: 'added', columnName: 'id', targetType: 'integer', changes: ['Added column "id" (integer)'] },
17
+ ],
18
+ indexes: [],
19
+ foreignKeys: [],
20
+ },
21
+ {
22
+ action: 'removed',
23
+ tableName: 'old_table',
24
+ columns: [
25
+ { action: 'removed', columnName: 'name', sourceType: 'varchar', changes: ['Removed column "name"'] },
26
+ ],
27
+ indexes: [],
28
+ foreignKeys: [],
29
+ },
30
+ {
31
+ action: 'modified',
32
+ tableName: 'users',
33
+ columns: [
34
+ { action: 'modified', columnName: 'email', sourceType: 'varchar(100)', targetType: 'varchar(255)', changes: ['Type changed: varchar(100) -> varchar(255)'] },
35
+ ],
36
+ indexes: [
37
+ { action: 'added', indexName: 'idx_email', changes: ['Added index idx_email'] },
38
+ { action: 'removed', indexName: 'idx_old', changes: ['Removed index idx_old'] },
39
+ { action: 'modified', indexName: 'idx_name', changes: ['Columns changed'] },
40
+ ],
41
+ foreignKeys: [
42
+ { action: 'added', columnName: 'org_id', changes: ['Added FK on org_id'] },
43
+ { action: 'removed', columnName: 'dept_id', changes: ['Removed FK on dept_id'] },
44
+ ],
45
+ },
46
+ ],
47
+ summary: { added: 1, removed: 1, modified: 1 },
48
+ hasChanges: true,
49
+ };
50
+
51
+ const mockDiffNoChanges = {
52
+ tables: [],
53
+ summary: { added: 0, removed: 0, modified: 0 },
54
+ hasChanges: false,
55
+ };
56
+
57
+ const mockDiffSchemas = mock(() => structuredClone(mockDiffWithChanges));
58
+ const mockGenerateMigrationSQL = mock(() => 'CREATE TABLE new_table (\n id integer\n);\nDROP TABLE old_table;');
59
+
60
+ mock.module('@/lib/schema-diff/diff-engine', () => ({
61
+ diffSchemas: mockDiffSchemas,
62
+ }));
63
+
64
+ mock.module('@/lib/schema-diff/migration-generator', () => ({
65
+ generateMigrationSQL: mockGenerateMigrationSQL,
66
+ }));
67
+
68
+ // ── Mock SnapshotTimeline ────────────────────────────────────────────────────
69
+
70
+ let capturedTimelineProps: { onCompare?: (s: string, t: string) => void; onDelete?: (id: string) => void } = {};
71
+
72
+ mock.module('@/components/SnapshotTimeline', () => ({
73
+ SnapshotTimeline: (props: { snapshots: unknown[]; onCompare?: (s: string, t: string) => void; onDelete?: (id: string) => void }) => {
74
+ capturedTimelineProps = { onCompare: props.onCompare, onDelete: props.onDelete };
75
+ return React.createElement('div', { 'data-testid': 'snapshot-timeline' }, `${props.snapshots.length} snapshots`);
76
+ },
77
+ }));
78
+
79
+ // ── Mock UI components ───────────────────────────────────────────────────────
80
+
81
+ mock.module('@/components/ui/button', () => ({
82
+ Button: ({ children, onClick, disabled, className, ...rest }: Record<string, unknown>) =>
83
+ React.createElement('button', { onClick: onClick as (() => void), disabled: disabled as boolean, className, ...rest }, children as React.ReactNode),
84
+ }));
85
+
86
+ mock.module('@/components/ui/badge', () => ({
87
+ Badge: ({ children, className }: { children: React.ReactNode; className?: string }) =>
88
+ React.createElement('span', { 'data-testid': 'badge', className }, children),
89
+ }));
90
+
91
+ // ── Mock Select: capture onValueChange callbacks ─────────────────────────────
92
+
93
+ // We store onValueChange keyed by the Select's current value prop.
94
+ // Source starts with value="current", Target starts with value="".
95
+ const selectCallbacks = new Map<string, (v: string) => void>();
96
+
97
+ mock.module('@/components/ui/select', () => ({
98
+ Select: ({ children, value, onValueChange }: { children: React.ReactNode; value?: string; onValueChange?: (v: string) => void }) => {
99
+ const key = value ?? '__empty__';
100
+ if (onValueChange) selectCallbacks.set(key, onValueChange);
101
+ return React.createElement('div', { 'data-testid': `select-${key}` }, children);
102
+ },
103
+ SelectTrigger: ({ children }: { children: React.ReactNode }) =>
104
+ React.createElement('div', { 'data-testid': 'select-trigger' }, children),
105
+ SelectContent: ({ children }: { children: React.ReactNode }) =>
106
+ React.createElement('div', { 'data-testid': 'select-content' }, children),
107
+ SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) =>
108
+ React.createElement('div', { 'data-testid': `select-item-${value}`, 'data-value': value }, children),
109
+ SelectValue: ({ placeholder }: { placeholder?: string }) =>
110
+ React.createElement('span', { 'data-testid': 'select-value' }, placeholder),
111
+ }));
112
+
113
+ // ── Mock storage ─────────────────────────────────────────────────────────────
114
+
115
+ const mockSnapshots = [
116
+ {
117
+ id: 'snap-1',
118
+ connectionId: 'test-pg-1',
119
+ connectionName: 'TestDB',
120
+ databaseType: 'postgres',
121
+ schema: [
122
+ { name: 'old_table', columns: [{ name: 'name', type: 'varchar', nullable: true, isPrimary: false }], indexes: [], foreignKeys: [] },
123
+ { name: 'users', columns: [{ name: 'email', type: 'varchar(100)', nullable: true, isPrimary: false }], indexes: [], foreignKeys: [] },
124
+ ],
125
+ createdAt: new Date('2026-01-10T10:00:00Z'),
126
+ label: 'Before migration',
127
+ },
128
+ ];
129
+
130
+ const mockGetSchemaSnapshots = mock(() => [...mockSnapshots]);
131
+ const mockSaveSchemaSnapshot = mock(() => {});
132
+ const mockDeleteSchemaSnapshot = mock(() => {});
133
+ const mockGetConnections = mock(() => [
134
+ { id: 'remote-1', name: 'Remote PG', type: 'postgres', host: 'remote', port: 5432, database: 'db', createdAt: new Date() },
135
+ { id: 'remote-2', name: 'Prod DB', type: 'postgres', host: 'prod', port: 5432, database: 'db', environment: 'production', createdAt: new Date() },
136
+ ]);
137
+
138
+ mock.module('@/lib/storage', () => ({
139
+ storage: {
140
+ getSchemaSnapshots: mockGetSchemaSnapshots,
141
+ saveSchemaSnapshot: mockSaveSchemaSnapshot,
142
+ deleteSchemaSnapshot: mockDeleteSchemaSnapshot,
143
+ getConnections: mockGetConnections,
144
+ },
145
+ }));
146
+
147
+ mock.module('@/hooks/use-all-connections', () => ({
148
+ useAllConnections: () => ({
149
+ connections: mockGetConnections(),
150
+ loading: false,
151
+ }),
152
+ }));
153
+
154
+ // ── Imports AFTER mocks ──────────────────────────────────────────────────────
155
+
156
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
157
+ import { render, fireEvent, cleanup, act } from '@testing-library/react';
158
+ import { SchemaDiff } from '@/components/SchemaDiff';
159
+ import { mockSchema } from '../fixtures/schemas';
160
+ import { mockPostgresConnection } from '../fixtures/connections';
161
+
162
+ // ── Helpers ──────────────────────────────────────────────────────────────────
163
+
164
+ function renderDiff(overrides: Partial<Parameters<typeof SchemaDiff>[0]> = {}) {
165
+ return render(
166
+ <SchemaDiff
167
+ schema={mockSchema}
168
+ connection={mockPostgresConnection}
169
+ {...overrides}
170
+ />
171
+ );
172
+ }
173
+
174
+ /** Trigger the source Select's onValueChange (source value starts as "current") */
175
+ function changeSource(value: string) {
176
+ const fn = selectCallbacks.get('current');
177
+ if (fn) act(() => fn(value));
178
+ }
179
+
180
+ /** Trigger the target Select's onValueChange (target value starts as "") */
181
+ function changeTarget(value: string) {
182
+ const fn = selectCallbacks.get('__empty__') || selectCallbacks.get('');
183
+ if (fn) act(() => fn(value));
184
+ }
185
+
186
+ /** Get the target callback for async tests (no act() wrapping) */
187
+ function getTargetCallback() {
188
+ return selectCallbacks.get('__empty__') || selectCallbacks.get('');
189
+ }
190
+
191
+ /** Helper to set native input value and trigger React change handler */
192
+ function changeInput(input: HTMLInputElement, value: string) {
193
+ // React controlled inputs need nativeInputValueSetter
194
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
195
+ if (nativeInputValueSetter) {
196
+ nativeInputValueSetter.call(input, value);
197
+ } else {
198
+ // fallback
199
+ Object.defineProperty(input, 'value', { value, writable: true, configurable: true });
200
+ }
201
+ fireEvent.input(input, { target: { value } });
202
+ fireEvent.change(input, { target: { value } });
203
+ }
204
+
205
+ describe('SchemaDiff', () => {
206
+ beforeEach(() => {
207
+ mockDiffSchemas.mockClear();
208
+ mockGenerateMigrationSQL.mockClear();
209
+ mockGetSchemaSnapshots.mockClear();
210
+ mockSaveSchemaSnapshot.mockClear();
211
+ mockDeleteSchemaSnapshot.mockClear();
212
+ mockGetConnections.mockClear();
213
+ selectCallbacks.clear();
214
+ capturedTimelineProps = {};
215
+
216
+ mockDiffSchemas.mockImplementation(() => structuredClone(mockDiffWithChanges));
217
+ mockGenerateMigrationSQL.mockImplementation(() => 'CREATE TABLE new_table (\n id integer\n);\nDROP TABLE old_table;');
218
+ mockGetSchemaSnapshots.mockImplementation(() => [...mockSnapshots]);
219
+ mockGetConnections.mockImplementation(() => [
220
+ { id: 'remote-1', name: 'Remote PG', type: 'postgres', host: 'remote', port: 5432, database: 'db', createdAt: new Date() },
221
+ { id: 'remote-2', name: 'Prod DB', type: 'postgres', host: 'prod', port: 5432, database: 'db', environment: 'production', createdAt: new Date() },
222
+ ]);
223
+ });
224
+
225
+ afterEach(() => {
226
+ cleanup();
227
+ });
228
+
229
+ // ═══════════════════════════════════════════════════════════════════════════
230
+ // Header
231
+ // ═══════════════════════════════════════════════════════════════════════════
232
+
233
+ describe('header', () => {
234
+ test('renders "Schema Diff" title', () => {
235
+ const { getByText } = renderDiff();
236
+ expect(getByText('Schema Diff')).toBeTruthy();
237
+ });
238
+
239
+ test('renders Source and Target labels', () => {
240
+ const { getByText } = renderDiff();
241
+ expect(getByText('Source')).toBeTruthy();
242
+ expect(getByText('Target')).toBeTruthy();
243
+ });
244
+
245
+ test('renders "vs" separator', () => {
246
+ const { getByText } = renderDiff();
247
+ expect(getByText('vs')).toBeTruthy();
248
+ });
249
+
250
+ test('renders "Current Schema" in select options', () => {
251
+ const { getAllByText } = renderDiff();
252
+ expect(getAllByText('Current Schema').length).toBeGreaterThanOrEqual(2);
253
+ });
254
+
255
+ test('renders snapshot items in select options', () => {
256
+ const { getAllByText } = renderDiff();
257
+ const items = getAllByText(/Before migration/);
258
+ expect(items.length).toBeGreaterThanOrEqual(1);
259
+ });
260
+ });
261
+
262
+ // ═══════════════════════════════════════════════════════════════════════════
263
+ // Empty State
264
+ // ═══════════════════════════════════════════════════════════════════════════
265
+
266
+ describe('empty state', () => {
267
+ test('shows instructions when no target selected', () => {
268
+ const { getByText } = renderDiff();
269
+ expect(getByText('Select source and target to compare schemas')).toBeTruthy();
270
+ expect(getByText('Take a snapshot first, then compare with the current schema')).toBeTruthy();
271
+ });
272
+
273
+ test('shows SnapshotTimeline when snapshots exist', () => {
274
+ const { container, getByText } = renderDiff();
275
+ expect(container.querySelector('[data-testid="snapshot-timeline"]')).toBeTruthy();
276
+ expect(getByText('1 snapshots')).toBeTruthy();
277
+ });
278
+
279
+ test('hides SnapshotTimeline when no snapshots', () => {
280
+ mockGetSchemaSnapshots.mockImplementation(() => []);
281
+ const { container } = renderDiff();
282
+ expect(container.querySelector('[data-testid="snapshot-timeline"]')).toBeNull();
283
+ });
284
+ });
285
+
286
+ // ═══════════════════════════════════════════════════════════════════════════
287
+ // Snapshot Controls
288
+ // ═══════════════════════════════════════════════════════════════════════════
289
+
290
+ describe('snapshot controls', () => {
291
+ test('renders Snapshot button', () => {
292
+ const { getByText } = renderDiff();
293
+ expect(getByText('Snapshot')).toBeTruthy();
294
+ });
295
+
296
+ test('Snapshot button is disabled when no connection', () => {
297
+ const { container } = renderDiff({ connection: null });
298
+ const snapshotBtn = Array.from(container.querySelectorAll('button')).find(
299
+ b => b.textContent?.includes('Snapshot')
300
+ );
301
+ expect(snapshotBtn?.disabled).toBe(true);
302
+ });
303
+
304
+ test('clicking Snapshot shows label input', () => {
305
+ const { getByText, getByPlaceholderText } = renderDiff();
306
+ fireEvent.click(getByText('Snapshot'));
307
+ expect(getByPlaceholderText('Label (optional)...')).toBeTruthy();
308
+ expect(getByText('Save')).toBeTruthy();
309
+ expect(getByText('Cancel')).toBeTruthy();
310
+ });
311
+
312
+ test('Cancel button hides label input', () => {
313
+ const { getByText, queryByPlaceholderText } = renderDiff();
314
+ fireEvent.click(getByText('Snapshot'));
315
+ expect(queryByPlaceholderText('Label (optional)...')).toBeTruthy();
316
+ fireEvent.click(getByText('Cancel'));
317
+ expect(queryByPlaceholderText('Label (optional)...')).toBeNull();
318
+ });
319
+
320
+ test('Save button calls storage.saveSchemaSnapshot', () => {
321
+ const { getByText, getByPlaceholderText, queryByPlaceholderText } = renderDiff();
322
+ fireEvent.click(getByText('Snapshot'));
323
+
324
+ const input = getByPlaceholderText('Label (optional)...') as HTMLInputElement;
325
+ changeInput(input, 'My label');
326
+ fireEvent.click(getByText('Save'));
327
+
328
+ expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
329
+ const saved = (mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as Record<string, unknown>;
330
+ expect(saved.connectionId).toBe(mockPostgresConnection.id);
331
+ expect(saved.connectionName).toBe(mockPostgresConnection.name);
332
+ expect(saved.databaseType).toBe(mockPostgresConnection.type);
333
+
334
+ // Label input should be hidden after save
335
+ expect(queryByPlaceholderText('Label (optional)...')).toBeNull();
336
+ });
337
+
338
+ test('Save with empty label sets label to undefined', () => {
339
+ const { getByText } = renderDiff();
340
+ fireEvent.click(getByText('Snapshot'));
341
+ fireEvent.click(getByText('Save'));
342
+
343
+ expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
344
+ expect(((mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as Record<string, unknown>).label).toBeUndefined();
345
+ });
346
+
347
+ test('Enter key in label input triggers snapshot save', () => {
348
+ const { getByText, getByPlaceholderText } = renderDiff();
349
+ fireEvent.click(getByText('Snapshot'));
350
+
351
+ const input = getByPlaceholderText('Label (optional)...') as HTMLInputElement;
352
+ changeInput(input, 'Enter label');
353
+ fireEvent.keyDown(input, { key: 'Enter' });
354
+
355
+ expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
356
+ });
357
+
358
+ test('takeSnapshot does nothing when connection is null', () => {
359
+ // Snapshot button is disabled for null connection, so storage should not be called
360
+ renderDiff({ connection: null });
361
+ expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
362
+ });
363
+
364
+ test('snapshot save refreshes snapshot list', () => {
365
+ const { getByText } = renderDiff();
366
+ const callsBefore = mockGetSchemaSnapshots.mock.calls.length;
367
+ fireEvent.click(getByText('Snapshot'));
368
+ fireEvent.click(getByText('Save'));
369
+ expect(mockGetSchemaSnapshots.mock.calls.length).toBeGreaterThan(callsBefore);
370
+ });
371
+ });
372
+
373
+ // ═══════════════════════════════════════════════════════════════════════════
374
+ // Source/Target Selection
375
+ // ═══════════════════════════════════════════════════════════════════════════
376
+
377
+ describe('source/target selection', () => {
378
+ test('selecting a target triggers diff display', () => {
379
+ const { queryByText } = renderDiff();
380
+ changeTarget('snap-1');
381
+ // diff has changes → summary should appear
382
+ expect(queryByText(/1 added, 1 removed, 1 modified/)).toBeTruthy();
383
+ });
384
+
385
+ test('selecting same source and target shows same-schema message', () => {
386
+ const { getByText } = renderDiff();
387
+ changeTarget('current');
388
+ // source=current, target=current → same → null diff
389
+ expect(getByText('Cannot compare same schema with itself')).toBeTruthy();
390
+ });
391
+
392
+ test('changing source updates diff', () => {
393
+ renderDiff();
394
+ changeSource('snap-1');
395
+ changeTarget('current');
396
+ expect(mockDiffSchemas).toHaveBeenCalled();
397
+ });
398
+ });
399
+
400
+ // ═══════════════════════════════════════════════════════════════════════════
401
+ // Diff View (hasChanges = true)
402
+ // ═══════════════════════════════════════════════════════════════════════════
403
+
404
+ describe('diff view with changes', () => {
405
+ function renderWithDiff() {
406
+ const result = renderDiff();
407
+ changeTarget('snap-1');
408
+ return result;
409
+ }
410
+
411
+ test('shows summary counts', () => {
412
+ const { getByText } = renderWithDiff();
413
+ expect(getByText(/1 added, 1 removed, 1 modified/)).toBeTruthy();
414
+ });
415
+
416
+ test('renders all table names in sidebar', () => {
417
+ const { getByText } = renderWithDiff();
418
+ expect(getByText('new_table')).toBeTruthy();
419
+ expect(getByText('old_table')).toBeTruthy();
420
+ expect(getByText('users')).toBeTruthy();
421
+ });
422
+
423
+ test('renders action badges for tables', () => {
424
+ const { getByText } = renderWithDiff();
425
+ expect(getByText('Added')).toBeTruthy();
426
+ expect(getByText('Removed')).toBeTruthy();
427
+ expect(getByText('Modified')).toBeTruthy();
428
+ });
429
+
430
+ test('shows "Select a table" prompt when no table is selected', () => {
431
+ const { getByText } = renderWithDiff();
432
+ expect(getByText('Select a table to view diff details')).toBeTruthy();
433
+ });
434
+
435
+ test('clicking a table shows its detail', () => {
436
+ const { getByText } = renderWithDiff();
437
+ fireEvent.click(getByText('new_table'));
438
+ // TableDiffDetail renders: table heading with action badge
439
+ const badges = document.querySelectorAll('[data-testid="badge"]');
440
+ const addedBadge = Array.from(badges).find(b => b.textContent === 'added');
441
+ expect(addedBadge).toBeTruthy();
442
+ });
443
+
444
+ test('clicking a different table switches detail', () => {
445
+ const { getByText } = renderWithDiff();
446
+ fireEvent.click(getByText('new_table'));
447
+ // new_table detail should show column "id"
448
+ expect(getByText('id')).toBeTruthy();
449
+
450
+ fireEvent.click(getByText('old_table'));
451
+ // old_table detail should show column "name"
452
+ expect(getByText('name')).toBeTruthy();
453
+ });
454
+
455
+ test('selected table has ChevronDown, others have ChevronRight', () => {
456
+ const { container, getByText } = renderWithDiff();
457
+ fireEvent.click(getByText('new_table'));
458
+
459
+ const tableButtons = Array.from(container.querySelectorAll('button'));
460
+ const newTableBtn = tableButtons.find(b => b.textContent?.includes('new_table'));
461
+ const oldTableBtn = tableButtons.find(b => b.textContent?.includes('old_table'));
462
+
463
+ expect(newTableBtn?.querySelector('.lucide-chevron-down')).toBeTruthy();
464
+ expect(oldTableBtn?.querySelector('.lucide-chevron-right')).toBeTruthy();
465
+ });
466
+
467
+ test('selected table has highlighted background', () => {
468
+ const { container, getByText } = renderWithDiff();
469
+ fireEvent.click(getByText('users'));
470
+
471
+ const usersBtn = Array.from(container.querySelectorAll('button')).find(
472
+ b => b.textContent?.includes('users')
473
+ );
474
+ expect(usersBtn?.className).toContain('bg-white/10');
475
+ });
476
+ });
477
+
478
+ // ═══════════════════════════════════════════════════════════════════════════
479
+ // No Changes State
480
+ // ═══════════════════════════════════════════════════════════════════════════
481
+
482
+ describe('no changes state', () => {
483
+ test('shows "No differences found" message', () => {
484
+ mockDiffSchemas.mockImplementation(() => structuredClone(mockDiffNoChanges));
485
+ const { getByText } = renderDiff();
486
+ changeTarget('snap-1');
487
+ expect(getByText('No differences found between source and target')).toBeTruthy();
488
+ });
489
+
490
+ test('SQL Migration button does not appear when no changes', () => {
491
+ mockDiffSchemas.mockImplementation(() => structuredClone(mockDiffNoChanges));
492
+ const { queryByText } = renderDiff();
493
+ changeTarget('snap-1');
494
+ expect(queryByText('SQL Migration')).toBeNull();
495
+ });
496
+ });
497
+
498
+ // ═══════════════════════════════════════════════════════════════════════════
499
+ // Migration SQL View
500
+ // ═══════════════════════════════════════════════════════════════════════════
501
+
502
+ describe('migration SQL', () => {
503
+ function renderWithDiff() {
504
+ const result = renderDiff();
505
+ changeTarget('snap-1');
506
+ return result;
507
+ }
508
+
509
+ test('SQL Migration button appears when diff has changes', () => {
510
+ const { getByText } = renderWithDiff();
511
+ expect(getByText('SQL Migration')).toBeTruthy();
512
+ });
513
+
514
+ test('clicking SQL Migration shows SQL and changes button text', () => {
515
+ const { getByText, container } = renderWithDiff();
516
+ fireEvent.click(getByText('SQL Migration'));
517
+
518
+ expect(container.textContent).toContain('CREATE TABLE new_table');
519
+ expect(container.textContent).toContain('DROP TABLE old_table');
520
+ expect(getByText('Diff View')).toBeTruthy();
521
+ });
522
+
523
+ test('toggling back to diff view shows table list again', () => {
524
+ const { getByText } = renderWithDiff();
525
+ fireEvent.click(getByText('SQL Migration'));
526
+ expect(getByText('Diff View')).toBeTruthy();
527
+
528
+ fireEvent.click(getByText('Diff View'));
529
+ expect(getByText('SQL Migration')).toBeTruthy();
530
+ expect(getByText('new_table')).toBeTruthy();
531
+ });
532
+
533
+ test('migration SQL is rendered in a pre tag', () => {
534
+ const { getByText, container } = renderWithDiff();
535
+ fireEvent.click(getByText('SQL Migration'));
536
+ const pre = container.querySelector('pre');
537
+ expect(pre).toBeTruthy();
538
+ expect(pre!.textContent).toContain('CREATE TABLE');
539
+ });
540
+
541
+ test('generateMigrationSQL receives correct dialect', () => {
542
+ renderWithDiff();
543
+ if (mockGenerateMigrationSQL.mock.calls.length > 0) {
544
+ const dialect = (mockGenerateMigrationSQL.mock.calls as unknown[][])[0][1];
545
+ expect(dialect).toBe('postgres');
546
+ }
547
+ });
548
+
549
+ test('defaults to postgres dialect when connection is null', () => {
550
+ renderDiff({ connection: null });
551
+ changeTarget('snap-1');
552
+ if (mockGenerateMigrationSQL.mock.calls.length > 0) {
553
+ const dialect = (mockGenerateMigrationSQL.mock.calls as unknown[][])[0][1];
554
+ expect(dialect).toBe('postgres');
555
+ }
556
+ });
557
+ });
558
+
559
+ // ═══════════════════════════════════════════════════════════════════════════
560
+ // TableDiffDetail Sub-Component
561
+ // ═══════════════════════════════════════════════════════════════════════════
562
+
563
+ describe('TableDiffDetail', () => {
564
+ function renderAndSelectTable(tableName: string) {
565
+ const result = renderDiff();
566
+ changeTarget('snap-1');
567
+ fireEvent.click(result.getByText(tableName));
568
+ return result;
569
+ }
570
+
571
+ // ── Header ──
572
+
573
+ test('shows table name and action badge', () => {
574
+ const { container } = renderAndSelectTable('new_table');
575
+ const badges = container.querySelectorAll('[data-testid="badge"]');
576
+ const addedBadge = Array.from(badges).find(b => b.textContent === 'added');
577
+ expect(addedBadge).toBeTruthy();
578
+ });
579
+
580
+ test('removed table shows removed badge', () => {
581
+ const { container } = renderAndSelectTable('old_table');
582
+ const badges = container.querySelectorAll('[data-testid="badge"]');
583
+ const removedBadge = Array.from(badges).find(b => b.textContent === 'removed');
584
+ expect(removedBadge).toBeTruthy();
585
+ });
586
+
587
+ test('modified table shows modified badge', () => {
588
+ const { container } = renderAndSelectTable('users');
589
+ const badges = container.querySelectorAll('[data-testid="badge"]');
590
+ const modifiedBadge = Array.from(badges).find(b => b.textContent === 'modified');
591
+ expect(modifiedBadge).toBeTruthy();
592
+ });
593
+
594
+ // ── Columns ──
595
+
596
+ test('renders "Columns" heading when columns exist', () => {
597
+ const { getByText } = renderAndSelectTable('users');
598
+ expect(getByText('Columns')).toBeTruthy();
599
+ });
600
+
601
+ test('renders added column with target type', () => {
602
+ const { getByText } = renderAndSelectTable('new_table');
603
+ expect(getByText('id')).toBeTruthy();
604
+ expect(getByText('integer')).toBeTruthy();
605
+ });
606
+
607
+ test('renders removed column with source type', () => {
608
+ const { getByText } = renderAndSelectTable('old_table');
609
+ expect(getByText('name')).toBeTruthy();
610
+ expect(getByText('varchar')).toBeTruthy();
611
+ });
612
+
613
+ test('renders modified column with change details', () => {
614
+ const { getByText } = renderAndSelectTable('users');
615
+ expect(getByText('email')).toBeTruthy();
616
+ expect(getByText('Type changed: varchar(100) -> varchar(255)')).toBeTruthy();
617
+ });
618
+
619
+ test('added column row has green background', () => {
620
+ const { getByText } = renderAndSelectTable('new_table');
621
+ const colRow = getByText('id').closest('div[class*="rounded"]');
622
+ expect(colRow?.className).toContain('bg-green-500/5');
623
+ });
624
+
625
+ test('removed column row has red background', () => {
626
+ const { getByText } = renderAndSelectTable('old_table');
627
+ const colRow = getByText('name').closest('div[class*="rounded"]');
628
+ expect(colRow?.className).toContain('bg-red-500/5');
629
+ });
630
+
631
+ test('modified column row has yellow background', () => {
632
+ const { getByText } = renderAndSelectTable('users');
633
+ const colRow = getByText('email').closest('div[class*="rounded"]');
634
+ expect(colRow?.className).toContain('bg-yellow-500/5');
635
+ });
636
+
637
+ // ── Indexes ──
638
+
639
+ test('renders "Indexes" heading when indexes exist', () => {
640
+ const { getByText } = renderAndSelectTable('users');
641
+ expect(getByText('Indexes')).toBeTruthy();
642
+ });
643
+
644
+ test('renders index names and changes', () => {
645
+ const { getByText } = renderAndSelectTable('users');
646
+ expect(getByText('idx_email')).toBeTruthy();
647
+ expect(getByText('idx_old')).toBeTruthy();
648
+ expect(getByText('idx_name')).toBeTruthy();
649
+ expect(getByText('Added index idx_email')).toBeTruthy();
650
+ expect(getByText('Removed index idx_old')).toBeTruthy();
651
+ expect(getByText('Columns changed')).toBeTruthy();
652
+ });
653
+
654
+ test('index rows have correct backgrounds', () => {
655
+ const { getByText } = renderAndSelectTable('users');
656
+ const addedIdx = getByText('idx_email').closest('div[class*="rounded"]');
657
+ expect(addedIdx?.className).toContain('bg-green-500/5');
658
+ const removedIdx = getByText('idx_old').closest('div[class*="rounded"]');
659
+ expect(removedIdx?.className).toContain('bg-red-500/5');
660
+ const modifiedIdx = getByText('idx_name').closest('div[class*="rounded"]');
661
+ expect(modifiedIdx?.className).toContain('bg-yellow-500/5');
662
+ });
663
+
664
+ test('does not render "Indexes" heading when no indexes', () => {
665
+ const { queryByText } = renderAndSelectTable('new_table');
666
+ expect(queryByText('Indexes')).toBeNull();
667
+ });
668
+
669
+ // ── Foreign Keys ──
670
+
671
+ test('renders "Foreign Keys" heading when FKs exist', () => {
672
+ const { getByText } = renderAndSelectTable('users');
673
+ expect(getByText('Foreign Keys')).toBeTruthy();
674
+ });
675
+
676
+ test('renders FK column names and changes', () => {
677
+ const { getByText } = renderAndSelectTable('users');
678
+ expect(getByText('org_id')).toBeTruthy();
679
+ expect(getByText('dept_id')).toBeTruthy();
680
+ expect(getByText('Added FK on org_id')).toBeTruthy();
681
+ expect(getByText('Removed FK on dept_id')).toBeTruthy();
682
+ });
683
+
684
+ test('FK rows have correct backgrounds', () => {
685
+ const { getByText } = renderAndSelectTable('users');
686
+ const addedFK = getByText('org_id').closest('div[class*="rounded"]');
687
+ expect(addedFK?.className).toContain('bg-green-500/5');
688
+ const removedFK = getByText('dept_id').closest('div[class*="rounded"]');
689
+ expect(removedFK?.className).toContain('bg-red-500/5');
690
+ });
691
+
692
+ test('does not render "Foreign Keys" heading when no FKs', () => {
693
+ const { queryByText } = renderAndSelectTable('new_table');
694
+ expect(queryByText('Foreign Keys')).toBeNull();
695
+ });
696
+ });
697
+
698
+ // ═══════════════════════════════════════════════════════════════════════════
699
+ // SnapshotTimeline Integration
700
+ // ═══════════════════════════════════════════════════════════════════════════
701
+
702
+ describe('SnapshotTimeline integration', () => {
703
+ test('onCompare callback sets source and target', () => {
704
+ renderDiff();
705
+ expect(capturedTimelineProps.onCompare).toBeDefined();
706
+
707
+ act(() => {
708
+ capturedTimelineProps.onCompare!('snap-1', 'current');
709
+ });
710
+
711
+ // Diff should be triggered
712
+ expect(mockDiffSchemas).toHaveBeenCalled();
713
+ });
714
+
715
+ test('onDelete callback removes snapshot', () => {
716
+ renderDiff();
717
+ expect(capturedTimelineProps.onDelete).toBeDefined();
718
+
719
+ act(() => {
720
+ capturedTimelineProps.onDelete!('snap-1');
721
+ });
722
+
723
+ expect(mockDeleteSchemaSnapshot).toHaveBeenCalledWith('snap-1');
724
+ });
725
+ });
726
+
727
+ // ═══════════════════════════════════════════════════════════════════════════
728
+ // Cross-Connection Comparison
729
+ // ═══════════════════════════════════════════════════════════════════════════
730
+
731
+ describe('cross-connection comparison', () => {
732
+ test('renders "Fetch from connection" section in target selector', () => {
733
+ const { getByText } = renderDiff();
734
+ expect(getByText('Fetch from connection')).toBeTruthy();
735
+ });
736
+
737
+ test('renders remote connections', () => {
738
+ const { getByText } = renderDiff();
739
+ expect(getByText('Remote PG')).toBeTruthy();
740
+ expect(getByText('Prod DB')).toBeTruthy();
741
+ });
742
+
743
+ test('does not show "Fetch from connection" when no other connections', () => {
744
+ mockGetConnections.mockImplementation(() => []);
745
+ const { queryByText } = renderDiff();
746
+ expect(queryByText('Fetch from connection')).toBeNull();
747
+ });
748
+
749
+ test('production connection shows warning icon', () => {
750
+ const { getByText } = renderDiff();
751
+ // Find Prod DB text and check its parent container for the AlertTriangle icon
752
+ const prodText = getByText('Prod DB');
753
+ const wrapper = prodText.closest('[data-testid^="select-item-"]') || prodText.parentElement;
754
+ expect(wrapper).toBeTruthy();
755
+ // Lucide renders class="lucide lucide-triangle-alert ..."
756
+ const alertIcon = wrapper!.querySelector('svg[class*="alert-triangle"], svg[class*="triangle-alert"]');
757
+ expect(alertIcon).toBeTruthy();
758
+ });
759
+
760
+ test('selecting a remote connection triggers API fetch', async () => {
761
+ const origFetch = globalThis.fetch;
762
+ const mockFetch = mock(() =>
763
+ Promise.resolve({
764
+ ok: true,
765
+ json: () => Promise.resolve({ schema: mockSchema }),
766
+ })
767
+ );
768
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
769
+
770
+ try {
771
+ renderDiff();
772
+ const fn = getTargetCallback();
773
+ expect(fn).toBeTruthy();
774
+
775
+ await act(async () => {
776
+ fn!('conn:remote-1');
777
+ });
778
+
779
+ expect(mockFetch).toHaveBeenCalledTimes(1);
780
+ const [url, options] = (mockFetch.mock.calls as unknown[][])[0] as [string, RequestInit];
781
+ expect(url).toBe('/api/db/schema-snapshot');
782
+ expect(JSON.parse(options.body as string).connection.id).toBe('remote-1');
783
+
784
+ expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
785
+ const saved = (mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as Record<string, unknown>;
786
+ expect(saved.label).toBe('Live: Remote PG');
787
+ } finally {
788
+ globalThis.fetch = origFetch;
789
+ }
790
+ });
791
+
792
+ test('shows "Fetching..." during remote fetch', async () => {
793
+ const origFetch = globalThis.fetch;
794
+ let resolveFetch!: (v: unknown) => void;
795
+ const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; });
796
+ globalThis.fetch = mock(() => fetchPromise) as unknown as typeof fetch;
797
+
798
+ try {
799
+ const { queryByText } = renderDiff();
800
+ const fn = getTargetCallback();
801
+
802
+ // Start the fetch synchronously, then check for Fetching...
803
+ act(() => {
804
+ fn!('conn:remote-1');
805
+ });
806
+
807
+ expect(queryByText('Fetching...')).toBeTruthy();
808
+
809
+ // Resolve the fetch
810
+ await act(async () => {
811
+ resolveFetch({ ok: true, json: () => Promise.resolve({ schema: [] }) });
812
+ });
813
+
814
+ expect(queryByText('Fetching...')).toBeNull();
815
+ } finally {
816
+ globalThis.fetch = origFetch;
817
+ }
818
+ });
819
+
820
+ test('handles fetch error gracefully', async () => {
821
+ const origFetch = globalThis.fetch;
822
+ const origError = console.error;
823
+ const mockConsoleError = mock(() => {});
824
+ console.error = mockConsoleError;
825
+
826
+ globalThis.fetch = mock(() =>
827
+ Promise.resolve({
828
+ ok: false,
829
+ json: () => Promise.resolve({ error: 'Unauthorized' }),
830
+ })
831
+ ) as unknown as typeof fetch;
832
+
833
+ try {
834
+ renderDiff();
835
+ const fn = getTargetCallback();
836
+
837
+ await act(async () => {
838
+ fn!('conn:remote-1');
839
+ });
840
+
841
+ expect(mockConsoleError).toHaveBeenCalled();
842
+ expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
843
+ } finally {
844
+ globalThis.fetch = origFetch;
845
+ console.error = origError;
846
+ }
847
+ });
848
+ });
849
+
850
+ // ═══════════════════════════════════════════════════════════════════════════
851
+ // formatSnapshotLabel
852
+ // ═══════════════════════════════════════════════════════════════════════════
853
+
854
+ describe('formatSnapshotLabel', () => {
855
+ test('snapshot with label shows label', () => {
856
+ const { getAllByText } = renderDiff();
857
+ const matches = getAllByText(/Before migration/);
858
+ expect(matches.length).toBeGreaterThanOrEqual(1);
859
+ });
860
+
861
+ test('snapshot without label shows connectionName', () => {
862
+ mockGetSchemaSnapshots.mockImplementation(() => [
863
+ { ...mockSnapshots[0], label: '' },
864
+ ]);
865
+ const { getAllByText } = renderDiff();
866
+ const matches = getAllByText(/TestDB/);
867
+ expect(matches.length).toBeGreaterThanOrEqual(1);
868
+ });
869
+ });
870
+
871
+ // ═══════════════════════════════════════════════════════════════════════════
872
+ // Action Badges (sidebar)
873
+ // ═══════════════════════════════════════════════════════════════════════════
874
+
875
+ describe('action badges', () => {
876
+ function renderWithDiff() {
877
+ const result = renderDiff();
878
+ changeTarget('snap-1');
879
+ return result;
880
+ }
881
+
882
+ test('added badge has green styling', () => {
883
+ const { container } = renderWithDiff();
884
+ const badges = container.querySelectorAll('[data-testid="badge"]');
885
+ const addedBadge = Array.from(badges).find(b => b.textContent?.includes('Added'));
886
+ expect(addedBadge).toBeTruthy();
887
+ expect(addedBadge!.className).toContain('bg-green-500/20');
888
+ });
889
+
890
+ test('removed badge has red styling', () => {
891
+ const { container } = renderWithDiff();
892
+ const badges = container.querySelectorAll('[data-testid="badge"]');
893
+ const removedBadge = Array.from(badges).find(b => b.textContent?.includes('Removed'));
894
+ expect(removedBadge).toBeTruthy();
895
+ expect(removedBadge!.className).toContain('bg-red-500/20');
896
+ });
897
+
898
+ test('modified badge has yellow styling', () => {
899
+ const { container } = renderWithDiff();
900
+ const badges = container.querySelectorAll('[data-testid="badge"]');
901
+ const modifiedBadge = Array.from(badges).find(b => b.textContent?.includes('Modified'));
902
+ expect(modifiedBadge).toBeTruthy();
903
+ expect(modifiedBadge!.className).toContain('bg-yellow-500/20');
904
+ });
905
+ });
906
+ });