@svgrid/enterprise 2.5.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (620) hide show
  1. package/dist/SvAlertRuleEditor.svelte.d.ts +13 -0
  2. package/dist/SvAlertsManager.svelte.d.ts +14 -0
  3. package/dist/SvAlertsPanel.svelte.d.ts +9 -0
  4. package/dist/SvAuthGate.svelte.d.ts +22 -0
  5. package/dist/SvBoard.svelte.d.ts +35 -0
  6. package/dist/SvExportMenu.svelte.d.ts +61 -0
  7. package/dist/SvExpressionEditor.svelte.d.ts +12 -0
  8. package/dist/SvFileInput.svelte.d.ts +20 -0
  9. package/dist/SvGridAlerts.svelte.d.ts +77 -0
  10. package/dist/SvGridBoard.svelte.d.ts +30 -0
  11. package/dist/SvGridEditPanel.svelte.d.ts +73 -0
  12. package/dist/SvGridMasterDetail.svelte.d.ts +49 -0
  13. package/dist/SvGridScheduler.svelte.d.ts +30 -0
  14. package/dist/SvImportDialog.svelte.d.ts +88 -0
  15. package/dist/SvLookupInput.svelte.d.ts +21 -0
  16. package/dist/SvPivotDesigner.svelte.d.ts +176 -0
  17. package/dist/SvRecordDetail.svelte.d.ts +58 -0
  18. package/dist/SvSchedule.svelte.d.ts +29 -0
  19. package/dist/SvSchemaChart.svelte.d.ts +61 -0
  20. package/dist/SvSchemaDashboard.svelte.d.ts +46 -0
  21. package/dist/alerts/alert-engine-attach.d.ts +79 -0
  22. package/dist/alerts/alert-engine-attach.js +106 -0
  23. package/dist/alerts/alert-engine.d.ts +42 -0
  24. package/dist/alerts/alert-engine.js +196 -0
  25. package/dist/alerts/alert-formats.d.ts +26 -0
  26. package/dist/alerts/alert-formats.js +58 -0
  27. package/dist/alerts/alert-observer.d.ts +54 -0
  28. package/dist/alerts/alert-observer.js +156 -0
  29. package/dist/alerts/alert-scheduler.d.ts +30 -0
  30. package/dist/alerts/alert-scheduler.js +54 -0
  31. package/dist/alerts/alert-storage.d.ts +31 -0
  32. package/dist/alerts/alert-storage.js +92 -0
  33. package/dist/alerts/alert-store.svelte.d.ts +24 -0
  34. package/dist/alerts/alert-store.svelte.js +69 -0
  35. package/dist/alerts/alert-types.d.ts +86 -0
  36. package/dist/alerts/alert-types.js +1 -0
  37. package/dist/alerts.d.ts +2 -0
  38. package/{src/alerts.ts → dist/alerts.js} +9 -9
  39. package/dist/board.d.ts +7 -0
  40. package/{src/board.ts → dist/board.js} +13 -14
  41. package/dist/cdn/svgrid-enterprise.svelte-external.js +1 -1
  42. package/dist/designer/assets/{index-CBYU-KNn.js → index-BF0UH638.js} +3 -3
  43. package/dist/designer/assets/{src-i309BeJb.js → src-BPJruwB9.js} +1 -1
  44. package/dist/designer/index.html +2 -2
  45. package/dist/edit-panel.d.ts +105 -0
  46. package/dist/edit-panel.js +350 -0
  47. package/dist/export-conditional.d.ts +31 -0
  48. package/dist/export-conditional.js +65 -0
  49. package/dist/export-ooxml.d.ts +93 -0
  50. package/dist/export-ooxml.js +385 -0
  51. package/dist/export-pdf.d.ts +107 -0
  52. package/dist/export-pdf.js +144 -0
  53. package/dist/export-print.d.ts +55 -0
  54. package/dist/export-print.js +91 -0
  55. package/{src/export-serialize.ts → dist/export-serialize.d.ts} +11 -24
  56. package/dist/export-serialize.js +11 -0
  57. package/dist/export-xls.d.ts +26 -0
  58. package/dist/export-xls.js +157 -0
  59. package/dist/export.d.ts +323 -0
  60. package/dist/export.js +1021 -0
  61. package/dist/expressions/evaluate.d.ts +17 -0
  62. package/dist/expressions/evaluate.js +214 -0
  63. package/dist/expressions/expression-columns.d.ts +61 -0
  64. package/dist/expressions/expression-columns.js +72 -0
  65. package/dist/expressions/expression-types.d.ts +125 -0
  66. package/dist/expressions/expression-types.js +1 -0
  67. package/dist/expressions/parse.d.ts +25 -0
  68. package/dist/expressions/parse.js +537 -0
  69. package/dist/import.d.ts +232 -0
  70. package/dist/import.js +846 -0
  71. package/dist/index.d.ts +66 -0
  72. package/dist/index.js +91 -0
  73. package/dist/install.d.ts +70 -0
  74. package/dist/install.js +61 -0
  75. package/dist/license-core.d.ts +37 -0
  76. package/dist/license-core.js +81 -0
  77. package/dist/license.d.ts +30 -0
  78. package/dist/license.js +139 -0
  79. package/dist/master-detail.d.ts +23 -0
  80. package/dist/master-detail.js +35 -0
  81. package/dist/node/studio.js +1 -1
  82. package/dist/pivot-chart.d.ts +28 -0
  83. package/dist/pivot-chart.js +71 -0
  84. package/dist/pivot-designer.d.ts +69 -0
  85. package/dist/pivot-designer.js +38 -0
  86. package/dist/pivot-enable.d.ts +6 -0
  87. package/{src/pivot-enable.ts → dist/pivot-enable.js} +13 -18
  88. package/dist/pivot.d.ts +111 -0
  89. package/dist/pivot.js +353 -0
  90. package/dist/print.d.ts +33 -0
  91. package/dist/print.js +61 -0
  92. package/dist/revoked.d.ts +1 -0
  93. package/{src/revoked.ts → dist/revoked.js} +5 -6
  94. package/dist/scheduler-assignments.d.ts +64 -0
  95. package/dist/scheduler-assignments.js +92 -0
  96. package/dist/scheduler-axis.d.ts +81 -0
  97. package/dist/scheduler-axis.js +180 -0
  98. package/{src/scheduler-booking.ts → dist/scheduler-booking.d.ts} +10 -43
  99. package/dist/scheduler-booking.js +50 -0
  100. package/dist/scheduler-config.d.ts +177 -0
  101. package/dist/scheduler-config.js +1 -0
  102. package/dist/scheduler-dependencies.d.ts +89 -0
  103. package/dist/scheduler-dependencies.js +179 -0
  104. package/dist/scheduler-freebusy.d.ts +19 -0
  105. package/{src/scheduler-freebusy.ts → dist/scheduler-freebusy.js} +9 -19
  106. package/{src/scheduler-heatmap.ts → dist/scheduler-heatmap.d.ts} +12 -35
  107. package/dist/scheduler-heatmap.js +41 -0
  108. package/dist/scheduler-resource-tree.d.ts +48 -0
  109. package/dist/scheduler-resource-tree.js +78 -0
  110. package/dist/scheduler-slots.d.ts +37 -0
  111. package/dist/scheduler-slots.js +80 -0
  112. package/dist/scheduler-summary.d.ts +41 -0
  113. package/dist/scheduler-summary.js +54 -0
  114. package/dist/scheduler.d.ts +7 -0
  115. package/{src/scheduler.ts → dist/scheduler.js} +13 -14
  116. package/dist/scheduling.d.ts +132 -0
  117. package/dist/scheduling.js +228 -0
  118. package/dist/schema-designer.d.ts +42 -0
  119. package/dist/schema-designer.js +117 -0
  120. package/dist/schema.d.ts +369 -0
  121. package/dist/schema.js +216 -0
  122. package/dist/smart-shim.d.ts +62 -0
  123. package/dist/smart-shim.js +34 -0
  124. package/dist/sources/aggregate.d.ts +43 -0
  125. package/dist/sources/aggregate.js +60 -0
  126. package/dist/sources/auth-supabase.d.ts +69 -0
  127. package/dist/sources/auth-supabase.js +39 -0
  128. package/dist/sources/dashboard.d.ts +65 -0
  129. package/dist/sources/dashboard.js +95 -0
  130. package/dist/sources/field-inference.d.ts +23 -0
  131. package/dist/sources/field-inference.js +52 -0
  132. package/dist/sources/filters.d.ts +35 -0
  133. package/dist/sources/filters.js +52 -0
  134. package/dist/sources/index.d.ts +18 -0
  135. package/dist/sources/index.js +18 -0
  136. package/dist/sources/introspect-supabase.d.ts +30 -0
  137. package/dist/sources/introspect-supabase.js +134 -0
  138. package/dist/sources/realtime-supabase.d.ts +37 -0
  139. package/dist/sources/realtime-supabase.js +52 -0
  140. package/dist/sources/relation-lookup.d.ts +50 -0
  141. package/dist/sources/relation-lookup.js +77 -0
  142. package/dist/sources/rest-adapters.d.ts +48 -0
  143. package/dist/sources/rest-adapters.js +85 -0
  144. package/dist/sources/rest.d.ts +38 -0
  145. package/dist/sources/rest.js +86 -0
  146. package/dist/sources/schema-from-columns.d.ts +37 -0
  147. package/dist/sources/schema-from-columns.js +48 -0
  148. package/dist/sources/supabase.d.ts +33 -0
  149. package/dist/sources/supabase.js +74 -0
  150. package/dist/sources/with-entity-rules.d.ts +25 -0
  151. package/dist/sources/with-entity-rules.js +47 -0
  152. package/dist/sources/with-relation-labels.d.ts +26 -0
  153. package/dist/sources/with-relation-labels.js +38 -0
  154. package/dist/staged-editing.d.ts +85 -0
  155. package/dist/staged-editing.js +0 -0
  156. package/dist/studio/bug-report.d.ts +61 -0
  157. package/dist/studio/bug-report.js +126 -0
  158. package/dist/studio/cli.d.ts +56 -0
  159. package/dist/studio/cli.js +70 -0
  160. package/dist/studio/copilot-core.d.ts +22 -0
  161. package/{src/studio/copilot-core.ts → dist/studio/copilot-core.js} +34 -38
  162. package/dist/studio/csv.d.ts +36 -0
  163. package/dist/studio/csv.js +173 -0
  164. package/dist/studio/db-connect-string.d.ts +45 -0
  165. package/dist/studio/db-connect-string.js +113 -0
  166. package/dist/studio/deploy-cli.d.ts +35 -0
  167. package/dist/studio/deploy-cli.js +81 -0
  168. package/dist/studio/emit-project.d.ts +77 -0
  169. package/dist/studio/emit-project.js +5389 -0
  170. package/dist/studio/emit-schema.d.ts +110 -0
  171. package/dist/studio/emit-schema.js +1128 -0
  172. package/dist/studio/index.d.ts +35 -0
  173. package/dist/studio/index.js +38 -0
  174. package/dist/studio/init-flow.d.ts +58 -0
  175. package/dist/studio/init-flow.js +451 -0
  176. package/dist/studio/introspect-db.d.ts +47 -0
  177. package/dist/studio/introspect-db.js +255 -0
  178. package/dist/studio/introspect-openapi.d.ts +19 -0
  179. package/dist/studio/introspect-openapi.js +244 -0
  180. package/dist/studio/introspect-prisma.d.ts +25 -0
  181. package/dist/studio/introspect-prisma.js +164 -0
  182. package/dist/studio/introspect.d.ts +35 -0
  183. package/dist/studio/introspect.js +306 -0
  184. package/dist/studio/project.d.ts +1327 -0
  185. package/dist/studio/project.js +1798 -0
  186. package/dist/studio/sample-data.d.ts +19 -0
  187. package/dist/studio/sample-data.js +207 -0
  188. package/dist/studio/samples/ats.d.ts +2 -0
  189. package/dist/studio/samples/ats.js +194 -0
  190. package/dist/studio/samples/clinic.d.ts +2 -0
  191. package/dist/studio/samples/clinic.js +171 -0
  192. package/dist/studio/samples/crm.d.ts +2 -0
  193. package/dist/studio/samples/crm.js +280 -0
  194. package/dist/studio/samples/datasets.d.ts +38 -0
  195. package/dist/studio/samples/datasets.js +296 -0
  196. package/dist/studio/samples/ecommerce.d.ts +2 -0
  197. package/dist/studio/samples/ecommerce.js +178 -0
  198. package/dist/studio/samples/events.d.ts +2 -0
  199. package/dist/studio/samples/events.js +192 -0
  200. package/dist/studio/samples/fleet.d.ts +2 -0
  201. package/dist/studio/samples/fleet.js +203 -0
  202. package/dist/studio/samples/gym.d.ts +2 -0
  203. package/dist/studio/samples/gym.js +205 -0
  204. package/dist/studio/samples/hr.d.ts +2 -0
  205. package/dist/studio/samples/hr.js +186 -0
  206. package/dist/studio/samples/index.d.ts +13 -0
  207. package/dist/studio/samples/index.js +44 -0
  208. package/dist/studio/samples/insurance.d.ts +2 -0
  209. package/dist/studio/samples/insurance.js +220 -0
  210. package/dist/studio/samples/inventory.d.ts +2 -0
  211. package/dist/studio/samples/inventory.js +204 -0
  212. package/dist/studio/samples/invoicing.d.ts +2 -0
  213. package/dist/studio/samples/invoicing.js +154 -0
  214. package/dist/studio/samples/library.d.ts +2 -0
  215. package/dist/studio/samples/library.js +171 -0
  216. package/dist/studio/samples/live-data.d.ts +5 -0
  217. package/dist/studio/samples/live-data.js +261 -0
  218. package/dist/studio/samples/projects.d.ts +2 -0
  219. package/dist/studio/samples/projects.js +203 -0
  220. package/dist/studio/samples/realestate.d.ts +2 -0
  221. package/dist/studio/samples/realestate.js +187 -0
  222. package/dist/studio/samples/restaurant.d.ts +2 -0
  223. package/dist/studio/samples/restaurant.js +180 -0
  224. package/dist/studio/samples/school.d.ts +2 -0
  225. package/dist/studio/samples/school.js +188 -0
  226. package/dist/studio/samples/shared.d.ts +142 -0
  227. package/dist/studio/samples/shared.js +164 -0
  228. package/dist/studio/samples/starter.d.ts +4 -0
  229. package/dist/studio/samples/starter.js +237 -0
  230. package/dist/studio/samples/subscriptions.d.ts +2 -0
  231. package/dist/studio/samples/subscriptions.js +175 -0
  232. package/dist/studio/samples/support.d.ts +2 -0
  233. package/dist/studio/samples/support.js +209 -0
  234. package/dist/studio/scaffold-app.d.ts +22 -0
  235. package/{src/studio/scaffold-app.ts → dist/studio/scaffold-app.js} +45 -63
  236. package/dist/studio/scaffold.d.ts +63 -0
  237. package/dist/studio/scaffold.js +346 -0
  238. package/dist/studio/screen-suites.d.ts +193 -0
  239. package/dist/studio/screen-suites.js +297 -0
  240. package/dist/studio/themes.d.ts +34 -0
  241. package/dist/studio/themes.js +41 -0
  242. package/dist/studio/ui-components.d.ts +89 -0
  243. package/dist/studio/ui-components.generated.d.ts +29 -0
  244. package/dist/studio/ui-components.generated.js +7139 -0
  245. package/dist/studio/ui-components.js +673 -0
  246. package/dist/studio/user-error.d.ts +23 -0
  247. package/{src/studio/user-error.ts → dist/studio/user-error.js} +10 -13
  248. package/dist/studio/verify.d.ts +34 -0
  249. package/dist/studio/verify.js +47 -0
  250. package/dist/sveltekit/in-memory.d.ts +14 -0
  251. package/dist/sveltekit/in-memory.js +113 -0
  252. package/dist/sveltekit/index.d.ts +6 -0
  253. package/dist/sveltekit/index.js +5 -0
  254. package/dist/sveltekit/query-plan.d.ts +44 -0
  255. package/dist/sveltekit/query-plan.js +76 -0
  256. package/dist/sveltekit/sql-source.d.ts +47 -0
  257. package/dist/sveltekit/sql-source.js +106 -0
  258. package/dist/sveltekit/sql.d.ts +48 -0
  259. package/dist/sveltekit/sql.js +73 -0
  260. package/dist/sveltekit/transport.d.ts +209 -0
  261. package/dist/sveltekit/transport.js +219 -0
  262. package/{src/sveltekit/types.ts → dist/sveltekit/types.d.ts} +8 -11
  263. package/dist/sveltekit/types.js +1 -0
  264. package/dist/upgrade-prompt.d.ts +16 -0
  265. package/dist/upgrade-prompt.js +141 -0
  266. package/dist/version.d.ts +13 -0
  267. package/{src/version.ts → dist/version.js} +1 -1
  268. package/dist/watermark.d.ts +2 -0
  269. package/dist/watermark.js +146 -0
  270. package/package.json +26 -24
  271. package/src/SvBoard.dom.test.ts +0 -67
  272. package/src/SvGridAlerts.dom.test.ts +0 -113
  273. package/src/SvGridEditPanel.dom.test.ts +0 -320
  274. package/src/SvRecordDetail.dom.test.ts +0 -137
  275. package/src/SvSchedule.dom.test.ts +0 -57
  276. package/src/ai-export-pdf.dom.test.ts +0 -77
  277. package/src/ai-export-xlsx.dom.test.ts +0 -90
  278. package/src/ai-export.dom.test.ts +0 -114
  279. package/src/alerts/alert-engine-attach.ts +0 -165
  280. package/src/alerts/alert-engine.test.ts +0 -135
  281. package/src/alerts/alert-engine.ts +0 -260
  282. package/src/alerts/alert-formats.test.ts +0 -87
  283. package/src/alerts/alert-formats.ts +0 -77
  284. package/src/alerts/alert-observer.test.ts +0 -189
  285. package/src/alerts/alert-observer.ts +0 -208
  286. package/src/alerts/alert-scheduler.ts +0 -96
  287. package/src/alerts/alert-storage.test.ts +0 -54
  288. package/src/alerts/alert-storage.ts +0 -116
  289. package/src/alerts/alert-store.svelte.ts +0 -80
  290. package/src/alerts/alert-types.ts +0 -94
  291. package/src/board.dom.test.ts +0 -941
  292. package/src/edit-panel.test.ts +0 -322
  293. package/src/edit-panel.ts +0 -379
  294. package/src/export-conditional.test.ts +0 -60
  295. package/src/export-conditional.ts +0 -94
  296. package/src/export-ooxml.test.ts +0 -152
  297. package/src/export-ooxml.ts +0 -489
  298. package/src/export-pdf.test.ts +0 -138
  299. package/src/export-pdf.ts +0 -245
  300. package/src/export-print.test.ts +0 -66
  301. package/src/export-print.ts +0 -132
  302. package/src/export-serialize.test.ts +0 -56
  303. package/src/export-xls.ts +0 -199
  304. package/src/export-xlsx-roundtrip.test.ts +0 -120
  305. package/src/export-xlsx.test.ts +0 -150
  306. package/src/export.test.ts +0 -349
  307. package/src/export.ts +0 -1464
  308. package/src/expressions/evaluate.test.ts +0 -111
  309. package/src/expressions/evaluate.ts +0 -237
  310. package/src/expressions/expression-columns.ts +0 -133
  311. package/src/expressions/expression-types.ts +0 -84
  312. package/src/expressions/parse.test.ts +0 -106
  313. package/src/expressions/parse.ts +0 -610
  314. package/src/import-automap.test.ts +0 -131
  315. package/src/import-hardening.test.ts +0 -158
  316. package/src/import.test.ts +0 -415
  317. package/src/import.ts +0 -1044
  318. package/src/index.ts +0 -569
  319. package/src/install.ts +0 -154
  320. package/src/license-core.test.ts +0 -23
  321. package/src/license-core.ts +0 -107
  322. package/src/license-expiry.test.ts +0 -144
  323. package/src/license.ts +0 -157
  324. package/src/master-detail.test.ts +0 -61
  325. package/src/master-detail.ts +0 -42
  326. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
  327. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js +0 -3875
  328. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js.map +0 -1
  329. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js +0 -21
  330. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js.map +0 -1
  331. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js +0 -1938
  332. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js.map +0 -1
  333. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js +0 -205
  334. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js.map +0 -1
  335. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js +0 -18
  336. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js.map +0 -1
  337. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js +0 -264
  338. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js.map +0 -1
  339. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js +0 -697
  340. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js.map +0 -1
  341. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
  342. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
  343. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
  344. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
  345. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
  346. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
  347. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
  348. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
  349. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
  350. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
  351. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
  352. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
  353. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
  354. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
  355. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
  356. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
  357. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
  358. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
  359. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
  360. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
  361. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
  362. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
  363. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
  364. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
  365. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
  366. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
  367. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
  368. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
  369. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
  370. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
  371. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
  372. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
  373. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
  374. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
  375. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
  376. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js +0 -4938
  377. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js.map +0 -1
  378. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js +0 -495
  379. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js.map +0 -1
  380. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js +0 -190
  381. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js.map +0 -1
  382. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js +0 -8
  383. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js.map +0 -1
  384. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js +0 -138
  385. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js.map +0 -1
  386. package/src/pivot-chart.test.ts +0 -86
  387. package/src/pivot-chart.ts +0 -112
  388. package/src/pivot-designer.ts +0 -101
  389. package/src/pivot.test.ts +0 -335
  390. package/src/pivot.ts +0 -578
  391. package/src/print.ts +0 -107
  392. package/src/scheduler-assignments.test.ts +0 -97
  393. package/src/scheduler-assignments.ts +0 -134
  394. package/src/scheduler-axis.test.ts +0 -108
  395. package/src/scheduler-axis.ts +0 -238
  396. package/src/scheduler-booking.test.ts +0 -57
  397. package/src/scheduler-config.ts +0 -179
  398. package/src/scheduler-dependencies.test.ts +0 -155
  399. package/src/scheduler-dependencies.ts +0 -223
  400. package/src/scheduler-freebusy.test.ts +0 -41
  401. package/src/scheduler-heatmap.test.ts +0 -39
  402. package/src/scheduler-resource-tree.test.ts +0 -84
  403. package/src/scheduler-resource-tree.ts +0 -107
  404. package/src/scheduler-slots.test.ts +0 -53
  405. package/src/scheduler-slots.ts +0 -94
  406. package/src/scheduler-summary.test.ts +0 -47
  407. package/src/scheduler-summary.ts +0 -81
  408. package/src/scheduling.test.ts +0 -194
  409. package/src/scheduling.ts +0 -293
  410. package/src/schema-designer.test.ts +0 -121
  411. package/src/schema-designer.ts +0 -142
  412. package/src/schema.test.ts +0 -268
  413. package/src/schema.ts +0 -562
  414. package/src/smart-shim.ts +0 -107
  415. package/src/sources/aggregate.test.ts +0 -79
  416. package/src/sources/aggregate.ts +0 -102
  417. package/src/sources/auth-supabase.test.ts +0 -80
  418. package/src/sources/auth-supabase.ts +0 -87
  419. package/src/sources/dashboard.kpi.test.ts +0 -50
  420. package/src/sources/dashboard.test.ts +0 -61
  421. package/src/sources/dashboard.ts +0 -136
  422. package/src/sources/field-inference.test.ts +0 -60
  423. package/src/sources/field-inference.ts +0 -63
  424. package/src/sources/filters.test.ts +0 -58
  425. package/src/sources/filters.ts +0 -70
  426. package/src/sources/index.ts +0 -67
  427. package/src/sources/introspect-supabase.test.ts +0 -112
  428. package/src/sources/introspect-supabase.ts +0 -139
  429. package/src/sources/realtime-supabase.test.ts +0 -93
  430. package/src/sources/realtime-supabase.ts +0 -99
  431. package/src/sources/relation-lookup.test.ts +0 -99
  432. package/src/sources/relation-lookup.ts +0 -128
  433. package/src/sources/rest-adapters.test.ts +0 -95
  434. package/src/sources/rest-adapters.ts +0 -120
  435. package/src/sources/rest.test.ts +0 -104
  436. package/src/sources/rest.ts +0 -129
  437. package/src/sources/schema-from-columns.test.ts +0 -106
  438. package/src/sources/schema-from-columns.ts +0 -88
  439. package/src/sources/supabase.test.ts +0 -110
  440. package/src/sources/supabase.ts +0 -99
  441. package/src/sources/with-entity-rules.test.ts +0 -104
  442. package/src/sources/with-entity-rules.ts +0 -78
  443. package/src/sources/with-relation-labels.test.ts +0 -75
  444. package/src/sources/with-relation-labels.ts +0 -73
  445. package/src/staged-editing.ts +0 -0
  446. package/src/studio/bug-report.test.ts +0 -101
  447. package/src/studio/bug-report.ts +0 -165
  448. package/src/studio/cli.test.ts +0 -187
  449. package/src/studio/cli.ts +0 -117
  450. package/src/studio/copilot-core.test.ts +0 -45
  451. package/src/studio/csv.test.ts +0 -90
  452. package/src/studio/csv.ts +0 -165
  453. package/src/studio/db-connect-string.test.ts +0 -94
  454. package/src/studio/db-connect-string.ts +0 -128
  455. package/src/studio/deploy-cli.test.ts +0 -56
  456. package/src/studio/deploy-cli.ts +0 -100
  457. package/src/studio/emit-project.test.ts +0 -3324
  458. package/src/studio/emit-project.ts +0 -5377
  459. package/src/studio/emit-schema.test.ts +0 -111
  460. package/src/studio/emit-schema.ts +0 -1181
  461. package/src/studio/index.ts +0 -380
  462. package/src/studio/init-flow.test.ts +0 -514
  463. package/src/studio/init-flow.ts +0 -556
  464. package/src/studio/introspect-db.test.ts +0 -186
  465. package/src/studio/introspect-db.ts +0 -312
  466. package/src/studio/introspect-openapi.test.ts +0 -84
  467. package/src/studio/introspect-openapi.ts +0 -252
  468. package/src/studio/introspect-prisma.test.ts +0 -99
  469. package/src/studio/introspect-prisma.ts +0 -175
  470. package/src/studio/introspect.test.ts +0 -172
  471. package/src/studio/introspect.ts +0 -310
  472. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
  473. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
  474. package/src/studio/pipeline.test.ts +0 -42
  475. package/src/studio/project-robust.test.ts +0 -157
  476. package/src/studio/project.test.ts +0 -929
  477. package/src/studio/project.ts +0 -2394
  478. package/src/studio/sample-data.test.ts +0 -58
  479. package/src/studio/sample-data.ts +0 -203
  480. package/src/studio/samples/ats.ts +0 -202
  481. package/src/studio/samples/clinic.ts +0 -179
  482. package/src/studio/samples/crm.ts +0 -291
  483. package/src/studio/samples/datasets.test.ts +0 -84
  484. package/src/studio/samples/datasets.ts +0 -340
  485. package/src/studio/samples/ecommerce.ts +0 -187
  486. package/src/studio/samples/events.ts +0 -201
  487. package/src/studio/samples/fleet.ts +0 -213
  488. package/src/studio/samples/gym.ts +0 -215
  489. package/src/studio/samples/hr.ts +0 -195
  490. package/src/studio/samples/index.ts +0 -55
  491. package/src/studio/samples/insurance.ts +0 -229
  492. package/src/studio/samples/inventory.ts +0 -213
  493. package/src/studio/samples/invoicing.ts +0 -162
  494. package/src/studio/samples/library.ts +0 -180
  495. package/src/studio/samples/live-data.test.ts +0 -99
  496. package/src/studio/samples/live-data.ts +0 -306
  497. package/src/studio/samples/projects.ts +0 -212
  498. package/src/studio/samples/realestate.ts +0 -196
  499. package/src/studio/samples/restaurant.ts +0 -189
  500. package/src/studio/samples/samples.test.ts +0 -304
  501. package/src/studio/samples/school.ts +0 -197
  502. package/src/studio/samples/seed-floor.test.ts +0 -25
  503. package/src/studio/samples/shared.ts +0 -237
  504. package/src/studio/samples/starter.ts +0 -251
  505. package/src/studio/samples/subscriptions.ts +0 -183
  506. package/src/studio/samples/support.ts +0 -216
  507. package/src/studio/scaffold-app.test.ts +0 -91
  508. package/src/studio/scaffold.test.ts +0 -192
  509. package/src/studio/scaffold.ts +0 -390
  510. package/src/studio/screen-suites.test.ts +0 -269
  511. package/src/studio/screen-suites.ts +0 -448
  512. package/src/studio/themes.ts +0 -62
  513. package/src/studio/ui-components-surface.test.ts +0 -185
  514. package/src/studio/ui-components.generated.ts +0 -7154
  515. package/src/studio/ui-components.ts +0 -742
  516. package/src/studio/verify.test.ts +0 -47
  517. package/src/studio/verify.ts +0 -79
  518. package/src/sveltekit/in-memory.test.ts +0 -104
  519. package/src/sveltekit/in-memory.ts +0 -129
  520. package/src/sveltekit/index.ts +0 -24
  521. package/src/sveltekit/query-plan.test.ts +0 -109
  522. package/src/sveltekit/query-plan.ts +0 -125
  523. package/src/sveltekit/sql-source.test.ts +0 -198
  524. package/src/sveltekit/sql-source.ts +0 -171
  525. package/src/sveltekit/sql.test.ts +0 -80
  526. package/src/sveltekit/sql.ts +0 -131
  527. package/src/sveltekit/transport-scope.test.ts +0 -125
  528. package/src/sveltekit/transport.test.ts +0 -201
  529. package/src/sveltekit/transport.ts +0 -373
  530. package/src/upgrade-prompt.test.ts +0 -71
  531. package/src/upgrade-prompt.ts +0 -162
  532. package/src/version.test.ts +0 -17
  533. package/src/watermark.test.ts +0 -97
  534. package/src/watermark.ts +0 -156
  535. /package/{src → dist}/SvAlertRuleEditor.svelte +0 -0
  536. /package/{src → dist}/SvAlertsManager.svelte +0 -0
  537. /package/{src → dist}/SvAlertsPanel.svelte +0 -0
  538. /package/{src → dist}/SvAuthGate.svelte +0 -0
  539. /package/{src → dist}/SvBoard.svelte +0 -0
  540. /package/{src → dist}/SvExportMenu.svelte +0 -0
  541. /package/{src → dist}/SvExpressionEditor.svelte +0 -0
  542. /package/{src → dist}/SvFileInput.svelte +0 -0
  543. /package/{src → dist}/SvGridAlerts.svelte +0 -0
  544. /package/{src → dist}/SvGridBoard.svelte +0 -0
  545. /package/{src → dist}/SvGridEditPanel.svelte +0 -0
  546. /package/{src → dist}/SvGridMasterDetail.svelte +0 -0
  547. /package/{src → dist}/SvGridScheduler.svelte +0 -0
  548. /package/{src → dist}/SvImportDialog.svelte +0 -0
  549. /package/{src → dist}/SvLookupInput.svelte +0 -0
  550. /package/{src → dist}/SvPivotDesigner.svelte +0 -0
  551. /package/{src → dist}/SvRecordDetail.svelte +0 -0
  552. /package/{src → dist}/SvSchedule.svelte +0 -0
  553. /package/{src → dist}/SvSchemaChart.svelte +0 -0
  554. /package/{src → dist}/SvSchemaDashboard.svelte +0 -0
  555. /package/{src → dist}/pdfmake-shims.d.ts +0 -0
  556. /package/{src → dist}/smart.export.d.ts +0 -0
  557. /package/{src → dist}/smart.export.js +0 -0
  558. /package/{src → dist}/studio/HANDLERS-DESIGN.md +0 -0
  559. /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -0
  560. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -0
  561. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -0
  562. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -0
  563. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -0
  564. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -0
  565. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -0
  566. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -0
  567. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -0
  568. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -0
  569. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -0
  570. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -0
  571. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -0
  572. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -0
  573. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -0
  574. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -0
  575. /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -0
  576. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -0
  577. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -0
  578. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -0
  579. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -0
  580. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -0
  581. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -0
  582. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -0
  583. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -0
  584. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -0
  585. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -0
  586. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -0
  587. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -0
  588. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -0
  589. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -0
  590. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -0
  591. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -0
  592. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -0
  593. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -0
  594. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -0
  595. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -0
  596. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -0
  597. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -0
  598. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -0
  599. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -0
  600. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -0
  601. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -0
  602. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -0
  603. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -0
  604. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -0
  605. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -0
  606. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -0
  607. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -0
  608. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -0
  609. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -0
  610. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -0
  611. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -0
  612. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -0
  613. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -0
  614. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -0
  615. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -0
  616. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -0
  617. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -0
  618. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -0
  619. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -0
  620. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +0 -0
@@ -1,3324 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { compile } from 'svelte/compiler'
3
- import type { EntitySchema } from '../schema'
4
- import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts, setDockPaneTitle, dockPaneTitleOf, syncDockPanes, dockPaneIds, removeBlock, setAuth, setComponentBinding, setDataLayer, setDeployTarget, setEntityDataSource, setJob, setTenancy, setTrigger, setHandlerBody, setHandlerSteps, stepsToCode, clickSlot, setScreenHandlersSource, setScreenRenderGrid, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type AccordionConfig, type StudioProject } from './project'
5
- import ts from 'typescript'
6
- import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
7
- import { crudAppFromSchemas } from './screen-suites'
8
- import { UI_COMPONENT_REGISTRY } from './ui-components'
9
-
10
- /** Type-check a handler BODY against a generated ambient ctx `.d.ts` using the real
11
- * TypeScript compiler - the same surface the in-editor language service uses.
12
- * Returns main.ts diagnostics (empty = clean). Mirrors the editor's function wrap. */
13
- function typeCheckBody(dts: string, body: string): string[] {
14
- const PRE = 'async function __run(ctx: PageContext): Promise<void> {\n'
15
- const files: Record<string, string> = { 'env.d.ts': dts, 'main.ts': `${PRE}${body}\n}\n` }
16
- const options: ts.CompilerOptions = {
17
- target: ts.ScriptTarget.ES2021, lib: ['lib.es2021.d.ts', 'lib.dom.d.ts'],
18
- module: ts.ModuleKind.ESNext, strict: false, noEmit: true, skipLibCheck: true, types: [],
19
- }
20
- const host = ts.createCompilerHost(options, true)
21
- const origGet = host.getSourceFile.bind(host)
22
- const origRead = host.readFile.bind(host)
23
- const origExists = host.fileExists.bind(host)
24
- host.getSourceFile = (name, langOrOpts, onErr, shouldCreate) =>
25
- files[name] != null ? ts.createSourceFile(name, files[name], langOrOpts, true) : origGet(name, langOrOpts, onErr, shouldCreate)
26
- host.readFile = (name) => files[name] ?? origRead(name)
27
- host.fileExists = (name) => files[name] != null || origExists(name)
28
- const program = ts.createProgram(['env.d.ts', 'main.ts'], options, host)
29
- const main = program.getSourceFile('main.ts')
30
- return [...program.getSemanticDiagnostics(main), ...program.getSyntacticDiagnostics(main)]
31
- .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
32
- }
33
-
34
- const customers: EntitySchema = {
35
- name: 'customers', label: 'Customer', idField: 'id',
36
- fields: [
37
- { field: 'id', type: 'text', primaryKey: true, hidden: { form: true } },
38
- { field: 'name', type: 'text' },
39
- { field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
40
- { field: 'mrr', type: 'number' },
41
- ],
42
- }
43
- const orders: EntitySchema = {
44
- name: 'orders', label: 'Order', idField: 'id',
45
- fields: [
46
- { field: 'id', type: 'text', primaryKey: true },
47
- { field: 'total', type: 'number' },
48
- { field: 'customer_id', type: 'relation', relation: { entity: 'customers', foreignKey: 'customer_id', labelField: 'name' } },
49
- ],
50
- }
51
-
52
- describe('SSR-native screens (renderMode: ssr)', () => {
53
- const ssrProject = (): StudioProject => {
54
- const p = createProject([customers])
55
- return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
56
- }
57
-
58
- it('updateScreen can switch a screen to SSR (the CLI / MCP path, not just the designer)', () => {
59
- // The other tests in this block set renderMode by spreading the screen object.
60
- // Going through updateScreen is what the CLI and the studio_update_screen MCP
61
- // tool do - and its patch type has to allow renderMode for that to be possible.
62
- const p0 = createProject([customers])
63
- const sid = p0.screens[0]!.id
64
- expect(emitStudioProject(p0).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
65
-
66
- const p1 = updateScreen(p0, sid, { renderMode: 'ssr' })
67
- expect(p1.screens[0]!.renderMode).toBe('ssr')
68
- const server = emitStudioProject(p1).find((f) => f.path === 'src/routes/customers/+page.server.ts')
69
- expect(server, 'updateScreen({ renderMode: "ssr" }) should reach the SSR emitter').toBeTruthy()
70
- expect(server!.contents).toContain('export const load')
71
- expect(server!.contents).toContain('export const actions')
72
-
73
- // And back again - the SPA page returns, the server page goes away.
74
- const p2 = updateScreen(p1, sid, { renderMode: 'spa' })
75
- expect(emitStudioProject(p2).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
76
- })
77
-
78
- it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
79
- const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
80
- expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
81
- const c = server!.contents
82
- expect(c).toMatch(/export const ssr = true/) // opts back into SSR over the SPA layout's ssr=false
83
- expect(c).toMatch(/export const load: PageServerLoad/)
84
- expect(c).toMatch(/export const actions: Actions/)
85
- expect(c).toMatch(/create:\s*async/)
86
- expect(c).toMatch(/update:\s*async/)
87
- expect(c).toMatch(/delete:\s*async/)
88
- expect(c).toMatch(/await validateAll\(customersSchema, values\)/) // server-side validation
89
- expect(c).toMatch(/return fail\(422/)
90
- expect(c).toMatch(/planFromSearchParams\(url/)
91
- expect(c).toMatch(/await customersSource\.getRows\(plan\)/)
92
- })
93
-
94
- it('emits an SSR +page.svelte: server rows, URL-driven sort/page, form-action editing', () => {
95
- const page = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
96
- expect(page).toMatch(/import type \{ PageProps \} from '\.\/\$types'/)
97
- expect(page).toMatch(/data=\{data\.rows\}/) // renders SSR'd rows
98
- expect(page).toMatch(/externalSort/)
99
- expect(page).toMatch(/initialSorting=\{data\.sort\}/) // header indicator matches the URL sort on hard reload
100
- expect(page).toMatch(/externalPagination/)
101
- expect(page).toMatch(/onSortingChange=/)
102
- expect(page).toMatch(/use:enhance=\{onSubmit\}/) // progressive enhancement
103
- expect(page).toMatch(/action=\{isCreate \? '\?\/create' : '\?\/update'\}/)
104
- // It must NOT fall back to the client data-source controller.
105
- expect(page).not.toMatch(/createServerDataSource/)
106
- // Valid Svelte 5.
107
- expect(() => compile(page, { generate: 'client' })).not.toThrow()
108
- })
109
-
110
- it('SSR grid honors filterable: URL-driven filter (q + f_<col>) via applyFilters; off when not filterable', () => {
111
- const p0 = createProject([customers])
112
- const withFilter: StudioProject = {
113
- ...p0,
114
- screens: p0.screens.map((s) => ({
115
- ...s,
116
- renderMode: 'ssr' as const,
117
- blocks: s.blocks.map((b) => (b.config.kind === 'grid' ? { ...b, config: { ...b.config, filterable: true } } : b)),
118
- })),
119
- }
120
- const page = emitStudioProject(withFilter).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
121
- expect(page).toMatch(/externalFilter/)
122
- expect(page).toMatch(/onFiltersChange=\{applyFilters\}/)
123
- expect(page).toMatch(/function applyFilters/)
124
- expect(page).toMatch(/sp\.set\('f_' \+ c\.id, c\.value\)/) // per-column f_ params
125
- // The default grid (filterable: false) omits the whole filter surface.
126
- const off = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
127
- expect(off).not.toMatch(/externalFilter/)
128
- expect(off).not.toMatch(/function applyFilters/)
129
- })
130
-
131
- it('emits the shared server query helper once', () => {
132
- const files = emitStudioProject(ssrProject())
133
- const helpers = files.filter((f) => f.path === 'src/lib/server/query.ts')
134
- expect(helpers).toHaveLength(1)
135
- expect(helpers[0]!.contents).toMatch(/export function planFromSearchParams/)
136
- })
137
-
138
- it('sql SSR screen: load/actions proxy to the connected /api route via createKitDataSource', () => {
139
- const p0 = createProject([customers])
140
- const p: StudioProject = {
141
- ...p0,
142
- dataSource: 'sql',
143
- dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' } },
144
- screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
145
- }
146
- const files = emitStudioProject(p)
147
- const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
148
- expect(server).toMatch(/import \{ validateAll, createKitDataSource \} from '@svgrid\/enterprise'/)
149
- expect(server).toMatch(/createKitDataSource<Customers>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
150
- expect(server).toMatch(/load: PageServerLoad = async \(\{ url, fetch \}\)/) // load gets event.fetch
151
- expect(server).not.toMatch(/from '\$lib\/data'/) // no in-process source import for sql
152
- // The connected /api route is still emitted - enforcement (validate/RBAC/triggers/audit) lives there.
153
- expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeTruthy()
154
- // Page still compiles.
155
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
156
- expect(() => compile(page, { generate: 'client' })).not.toThrow()
157
- })
158
-
159
- it('read-only SSR screens (dashboard/kpi): load-only server file + page renders from data.*', () => {
160
- let p = createProject([customers])
161
- const sid = p.screens[0]!.id
162
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
163
- p = addBlock(p, sid, 'kpi')
164
- p = addBlock(p, sid, 'chart')
165
- const files = emitStudioProject(p)
166
- const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
167
- expect(server).toMatch(/export const ssr = true/)
168
- expect(server).toMatch(/export const load: PageServerLoad/)
169
- expect(server).toMatch(/const rows = \(await customersSource\.getRows\(PAGE\)\)\.rows/)
170
- expect(server).not.toMatch(/export const actions/) // read-only
171
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
172
- expect(page).toMatch(/let \{ data \}: PageProps = \$props\(\)/)
173
- expect(page).toMatch(/const allRows = \$derived\(data\.rows as Customers\[\]\)/)
174
- expect(page).not.toMatch(/loadAll\(\)/)
175
- expect(page).not.toMatch(/from '\$lib\/data'/) // all data comes from the server load
176
- expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
177
- })
178
-
179
- it('read-only SSR + master-detail: child collections load server-side too (sql via /api)', () => {
180
- const p0 = createProject([customers, orders])
181
- let p: StudioProject = {
182
- ...p0,
183
- dataSource: 'sql',
184
- dataSources: {
185
- customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
186
- orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
187
- },
188
- }
189
- const sid = p.screens[0]!.id
190
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
191
- p = addBlock(p, sid, 'master-detail')
192
- const mdId = p.screens[0]!.blocks.at(-1)!.id
193
- p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
194
- const files = emitStudioProject(p)
195
- const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
196
- expect(server).toMatch(/createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
197
- expect(server).toMatch(/const md_orders_rows = \(await createKitDataSource.*'\/api\/orders'/)
198
- expect(server).toMatch(/return \{ rows, md_orders_rows \}/)
199
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
200
- expect(page).toMatch(/const md_orders_rows = \$derived\(data\.md_orders_rows as Orders\[\]\)/)
201
- expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
202
- })
203
-
204
- it('a board screen stays SPA even with renderMode ssr (client-only interaction)', () => {
205
- let p = createProject([customers])
206
- const sid = p.screens[0]!.id
207
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
208
- p = addBlock(p, sid, 'board')
209
- const files = emitStudioProject(p)
210
- expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
211
- expect(files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents).toMatch(/loadAll\(\)/)
212
- })
213
-
214
- it('a facet panel beside the grid becomes a GET form the server can filter on', () => {
215
- // Every list screen the onboarding generator produces is [filter, grid], so
216
- // if the facet panel did not survive into SSR, no generated app could ever
217
- // render on the server.
218
- const app = crudAppFromSchemas([customers, orders], {
219
- dataSource: 'sql',
220
- sources: {
221
- customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
222
- orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
223
- },
224
- })
225
- const list = app.screens.find((s) => s.id === 'customers')!
226
- expect(list.blocks.map((b) => b.config.kind)).toContain('filter')
227
- expect(list.renderMode).toBe('ssr')
228
-
229
- const files = emitStudioProject(app)
230
- const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!
231
- // The load + actions module has to at least parse as TypeScript.
232
- expect(
233
- ts.transpileModule(server.contents, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.ES2021 } })
234
- .diagnostics?.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')) ?? [],
235
- ).toEqual([])
236
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
237
- expect(page).toContain('<form method="GET" class="sk-facets">')
238
- // Named for the param planFromSearchParams reads, so no JS is needed to filter.
239
- expect(page).toMatch(/<select name='f_tier'>/)
240
- expect(page).toMatch(/name="sort"/) // sort survives a facet submit
241
- // `===` binds tighter than `??`: an unparenthesised current-value read would
242
- // hand `selected` the raw param and mark every option at once.
243
- expect(page).toMatch(/selected=\{\(page\.url\.searchParams\.get\('f_tier'\) \?\? ''\) === 'pro'\}/)
244
- for (const f of files) expect(f.contents, f.path).not.toMatch(/\?\?\s*''\s*===/)
245
- // It renders on the server, so it has to compile both ways.
246
- for (const mode of ['client', 'server'] as const) {
247
- expect(() => compile(page, { filename: 'p.svelte', generate: mode }), mode).not.toThrow()
248
- }
249
- })
250
-
251
- it('an app with SSR screens keeps SvelteKit\'s server default and opts out per client screen', () => {
252
- // The root +layout.ts is the first file a reviewer opens. Announcing
253
- // `ssr = false` there makes the whole app read as a SPA even when most of its
254
- // screens render on the server.
255
- const app = crudAppFromSchemas([customers, orders], {
256
- dataSource: 'sql',
257
- sources: {
258
- customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
259
- orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
260
- },
261
- })
262
- const bundle = emitStudioAppBundle(app)
263
- const layout = bundle.find((f) => f.path === 'src/routes/+layout.ts')!.contents
264
- expect(layout).not.toMatch(/export const ssr = false/)
265
- expect(layout).toMatch(/export const prerender = false/)
266
-
267
- // Every screen that still runs in the browser says so itself, so nothing
268
- // starts rendering server-side that was never written for it.
269
- for (const screen of app.screens) {
270
- const optOut = bundle.find((f) => f.path === `src/routes/${screen.route}/+page.ts`)
271
- if (screen.renderMode === 'ssr') expect(optOut, `${screen.id} is SSR and needs no opt-out`).toBeUndefined()
272
- else expect(optOut?.contents, `${screen.id} runs client-side and must opt out`).toMatch(/export const ssr = false/)
273
- }
274
- })
275
-
276
- it('a REST entity on an absolute URL renders on the server, calling the API directly', () => {
277
- const rest = { kind: 'rest' as const, baseUrl: 'https://api.example.com', path: 'customers', method: 'GET' as const, params: [] }
278
- const app = crudAppFromSchemas([customers], { dataSource: 'rest', sources: { customers: rest } })
279
- expect(app.screens.find((s) => s.id === 'customers')!.renderMode).toBe('ssr')
280
-
281
- const server = emitStudioProject(app).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
282
- // Reads through the in-process source like memory does...
283
- expect(server).toMatch(/import \{ customersSource \} from '\$lib\/data'/)
284
- // ...but the remote API owns the ids, so a create posts only the values.
285
- expect(server).toMatch(/await customersSource\.createRow\(values\)/)
286
- expect(server).not.toMatch(/nextId/)
287
- })
288
-
289
- it('a relative REST url stays SPA - there is no origin to fetch from on the server', () => {
290
- const rest = { kind: 'rest' as const, baseUrl: '', path: '/api/customers', method: 'GET' as const, params: [] }
291
- const app = crudAppFromSchemas([customers], { dataSource: 'rest', sources: { customers: rest } })
292
- expect(app.screens.every((s) => s.renderMode === undefined)).toBe(true)
293
- })
294
-
295
- it('the SSR form follows the schema layout and states the field constraints', () => {
296
- // The server-rendered form used to be a flat single column that ignored the
297
- // layout entirely, so the same entity looked like two different forms
298
- // depending on which path rendered it.
299
- const laidOut: EntitySchema = {
300
- name: 'people',
301
- idField: 'id',
302
- fields: [
303
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
304
- { field: 'name', type: 'text', required: true, minLength: 2, maxLength: 60, input: { placeholder: 'Ada Lovelace' } },
305
- { field: 'email', type: 'text', format: 'email', input: { help: 'We only use this for receipts.' } },
306
- { field: 'notes', type: 'text', input: { editorType: 'textarea', span: 2 } },
307
- ],
308
- form: { columns: 2, sections: [{ title: 'Contact', description: 'How we reach them.', fields: ['name', 'email'] }] },
309
- }
310
- let p = createProject([laidOut])
311
- p = { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
312
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/people/+page.svelte')!.contents
313
-
314
- expect(page).toContain('<legend>Contact</legend>')
315
- expect(page).toContain('How we reach them.')
316
- expect(page).toMatch(/--sk-cols: 2/)
317
- // Constraints the browser can enforce, mirroring what the action re-checks.
318
- expect(page).toMatch(/minlength="2"/)
319
- expect(page).toMatch(/maxlength="60"/)
320
- expect(page).toMatch(/placeholder="Ada Lovelace"/)
321
- expect(page).toMatch(/<input type="email"/)
322
- expect(page).toContain('We only use this for receipts.')
323
- expect(page).toMatch(/<textarea name='notes'/)
324
- expect(page).toContain('sk-field--wide')
325
- // The unassigned field still renders, in a trailing group.
326
- expect(page).toMatch(/name='notes'/)
327
-
328
- for (const mode of ['client', 'server'] as const) {
329
- expect(() => compile(page, { filename: 'p.svelte', generate: mode }), mode).not.toThrow()
330
- }
331
- })
332
-
333
- it('a layout arranged on the grid block reaches the SSR form too', () => {
334
- // The designer writes the form arrangement onto the grid block. Before this,
335
- // only the client panel read it, so what you arranged in the designer was not
336
- // what a server-rendered screen drew.
337
- let p = createProject([customers])
338
- const sid = p.screens[0]!.id
339
- const gid = p.screens[0]!.blocks[0]!.id
340
- p = updateBlock(p, sid, gid, {
341
- config: {
342
- editing: 'form',
343
- formColumns: 3,
344
- formSections: [{ title: 'Identity', description: 'Who they are.', fields: ['name'] }],
345
- } as Partial<GridConfig>,
346
- })
347
- p = { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
348
-
349
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
350
- expect(page).toContain('<legend>Identity</legend>')
351
- expect(page).toContain('Who they are.')
352
- expect(page).toMatch(/--sk-cols: 3/)
353
- expect(() => compile(page, { filename: 'p.svelte', generate: 'server' })).not.toThrow()
354
- })
355
-
356
- it('an in-memory app stays a SPA at the layout, with no per-screen opt-outs', () => {
357
- const app = crudAppFromSchemas([customers])
358
- const bundle = emitStudioAppBundle(app)
359
- expect(bundle.find((f) => f.path === 'src/routes/+layout.ts')!.contents).toMatch(/export const ssr = false/)
360
- expect(bundle.filter((f) => f.path.endsWith('/+page.ts'))).toEqual([])
361
- })
362
-
363
- it('sql SSR + relation field: prefetches options via the related /api route', () => {
364
- const p0 = createProject([customers, orders])
365
- const p: StudioProject = {
366
- ...p0,
367
- dataSource: 'sql',
368
- dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' }, orders: { kind: 'sql', dialect: 'postgres', table: 'orders' } },
369
- screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)),
370
- }
371
- const server = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
372
- expect(server).toMatch(/const customer_idOptions = \(await createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)\.getRows\(/)
373
- expect(server).toMatch(/, customer_idOptions \}/)
374
- expect(server).not.toMatch(/from '\$lib\/data'/) // sql uses the /api route, not the in-process source
375
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
376
- expect(page).toMatch(/<select name='customer_id'/)
377
- expect(page).toMatch(/#each data\.customer_idOptions as o/)
378
- })
379
-
380
- it('memory SSR + RBAC: load/actions enforce authz inline (sql inherits from /api)', () => {
381
- const p0 = createProject([customers])
382
- const p: StudioProject = {
383
- ...p0,
384
- access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: p0.screens.map((s) => s.id), actions: [] }] },
385
- screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
386
- }
387
- const server = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
388
- expect(server).toMatch(/import \{ fail, error \} from '@sveltejs\/kit'/)
389
- expect(server).toMatch(/import \{ authorizeAction, getServerRole \} from '\$lib\/access'/)
390
- expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'read', SCREEN_IDS\)/)
391
- expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'create', SCREEN_IDS\)/)
392
- expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'delete', SCREEN_IDS\)/)
393
- })
394
-
395
- it('memory SSR + relation field: prefetches options in load and renders a <select>', () => {
396
- const p0 = createProject([customers, orders])
397
- const p: StudioProject = { ...p0, screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)) }
398
- const files = emitStudioProject(p)
399
- const server = files.find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
400
- expect(server).toMatch(/import \{ ordersSource, customersSource, nextId \} from '\$lib\/data'/)
401
- expect(server).toMatch(/const customer_idOptions = \(await customersSource\.getRows\(/)
402
- expect(server).toMatch(/label: String\(r\['name'\]/) // uses the relation's labelField
403
- expect(server).toMatch(/, customer_idOptions \}/) // options returned from load
404
- const page = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
405
- expect(page).toMatch(/<select name='customer_id'/)
406
- expect(page).toMatch(/#each data\.customer_idOptions as o/)
407
- expect(() => compile(page, { generate: 'client' })).not.toThrow()
408
- })
409
-
410
- it('leaves spa screens on the client-controller path', () => {
411
- const p = createProject([customers]) // default renderMode is spa
412
- const files = emitStudioProject(p)
413
- expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
414
- expect(files.find((f) => f.path === 'src/lib/server/query.ts')).toBeUndefined()
415
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
416
- expect(page).toMatch(/createServerDataSource/)
417
- })
418
- })
419
-
420
- describe('emitStudioFragment (drop into an existing app)', () => {
421
- it('emits app content minus the shell, plus app.css + FRAGMENT.md', () => {
422
- const files = emitStudioFragment(createProject([customers]))
423
- const paths = files.map((f) => f.path)
424
- // No whole-app scaffolding.
425
- expect(paths).not.toContain('package.json')
426
- expect(paths).not.toContain('svelte.config.js')
427
- expect(paths).not.toContain('src/routes/+layout.svelte') // the nav shell
428
- expect(paths).not.toContain('src/routes/+page.svelte') // the home redirect
429
- // The screens + lib + the lifted styles + the readme ARE present.
430
- expect(paths).toContain('src/routes/customers/+page.svelte')
431
- expect(paths).toContain('src/lib/schemas.ts')
432
- expect(paths).toContain('src/lib/data.ts')
433
- expect(paths).toContain('src/app.css')
434
- expect(paths).toContain('FRAGMENT.md')
435
- })
436
-
437
- it('FRAGMENT.md reports the runtime deps + host requirements', () => {
438
- let p = createProject([customers])
439
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
440
- const md = emitStudioFragment(p).find((f) => f.path === 'FRAGMENT.md')!.contents
441
- expect(md).toMatch(/npm install .*\bpg\b/) // the sql driver the fragment imports
442
- expect(md).toMatch(/DATABASE_URL/) // env the host must set
443
- expect(md).toMatch(/Import `src\/app\.css`/)
444
- expect(md).toMatch(/nav shell.*NOT here/s)
445
- })
446
- })
447
-
448
- describe('db/schema.sql + db/seed.sql + drizzle db:seed (real-DB last mile)', () => {
449
- const sqlProject = (dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'turso'): StudioProject => {
450
- let p = createProject([customers, orders])
451
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
452
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect })
453
- return p
454
- }
455
-
456
- it('emits db/schema.sql + db/seed.sql for SQL entities without the Drizzle layer', () => {
457
- const files = emitStudioProject(sqlProject('postgres'))
458
- const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
459
- expect(ddl).toMatch(/CREATE TABLE IF NOT EXISTS "customers" \(/)
460
- expect(ddl).toMatch(/"id" text PRIMARY KEY/)
461
- expect(ddl).toMatch(/"mrr" double precision/)
462
- // Relation display fields are runtime labels, not columns.
463
- const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
464
- expect(seedSql).toMatch(/INSERT INTO "customers" \(/)
465
- expect(seedSql).toMatch(/INSERT INTO "orders" \(/)
466
- })
467
-
468
- it('mssql gets a guarded CREATE TABLE (no Drizzle possible) with N-literals', () => {
469
- const files = emitStudioProject(sqlProject('mssql'))
470
- const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
471
- expect(ddl).toMatch(/IF OBJECT_ID\(N'customers', N'U'\) IS NULL/)
472
- expect(ddl).toMatch(/"mrr" float/)
473
- const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
474
- expect(seedSql).toMatch(/N'/)
475
- // Even WITH the drizzle toggle, mssql still ships plain SQL (dzDialect is null).
476
- const withLayer = emitStudioProject(setDataLayer(sqlProject('mssql'), true))
477
- expect(withLayer.some((f) => f.path === 'db/schema.sql')).toBe(true)
478
- })
479
-
480
- it('mysql uses backticks; sqlite/turso use sqlite types', () => {
481
- const my = emitStudioProject(sqlProject('mysql')).find((f) => f.path === 'db/schema.sql')!.contents
482
- expect(my).toMatch(/CREATE TABLE IF NOT EXISTS `customers` \(/)
483
- expect(my).toMatch(/`mrr` double/)
484
- const tu = emitStudioProject(sqlProject('turso')).find((f) => f.path === 'db/schema.sql')!.contents
485
- expect(tu).toMatch(/"mrr" real/)
486
- })
487
-
488
- it('the Drizzle layer replaces plain DDL with migrations + a db:seed script', () => {
489
- const p = setDataLayer(sqlProject('postgres'), true)
490
- const files = emitStudioAppBundle(p)
491
- expect(files.some((f) => f.path === 'db/schema.sql')).toBe(false) // migrations own DDL
492
- const seedTs = files.find((f) => f.path === 'db/seed.ts')!.contents
493
- expect(seedTs).toMatch(/studio:db-seed/)
494
- expect(seedTs).toMatch(/process\.env\.DATABASE_URL/) // own client - no \$env import
495
- expect(seedTs).not.toMatch(/\$env\/dynamic\/private/)
496
- expect(seedTs).toMatch(/db\.insert\(schema\.customers\)/)
497
- expect(seedTs).toMatch(/existing\.length === 0/) // idempotent
498
- const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
499
- expect(pkg.scripts['db:seed']).toBe('tsx db/seed.ts')
500
- expect(pkg.devDependencies.tsx).toBeTruthy()
501
- const deploy = files.find((f) => f.path === 'DEPLOY.md')!.contents
502
- expect(deploy).toMatch(/npm run db:seed/)
503
- })
504
-
505
- it('DEPLOY.md points at db/schema.sql on the non-Drizzle path', () => {
506
- const deploy = emitStudioAppBundle(sqlProject('postgres')).find((f) => f.path === 'DEPLOY.md')!.contents
507
- expect(deploy).toMatch(/db\/schema\.sql/)
508
- expect(deploy).toMatch(/VITE_SVPRO_KEY/)
509
- })
510
- })
511
-
512
- describe('secure-by-default auth', () => {
513
- const authApp = () => emitStudioAppBundle(setAuth(createProject([customers]), { enabled: true, protect: true }))
514
-
515
- it('ships a git-ignored .env with a real random SESSION_SECRET (not the hardcoded fallback)', () => {
516
- const env = authApp().find((f) => f.path === '.env')
517
- expect(env, 'an auth app should ship a .env with a real secret').toBeTruthy()
518
- expect(env!.contents).toMatch(/SESSION_SECRET=[0-9a-f]{64}/)
519
- expect(env!.contents).not.toMatch(/change-me-to-a-long-random-string/)
520
- })
521
-
522
- it('hashes demo users - no plaintext store or plaintext login compare', () => {
523
- const files = authApp()
524
- const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
525
- expect(users).toMatch(/passwordHash: await hashPassword/)
526
- // The stored user type carries only the hash (the SEED keeps plaintext defaults,
527
- // which are hashed at module load).
528
- expect(users).toMatch(/AppUser = \{ email: string; name: string; role: string; passwordHash: string \}/)
529
- const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
530
- expect(login).toMatch(/await verifyPassword\(password, user\.passwordHash\)/)
531
- expect(login).not.toMatch(/user\.password !== password/)
532
- })
533
- })
534
-
535
- describe('emitStudioProject (per-block screens)', () => {
536
- const project = createProject([customers, orders])
537
- const files = emitStudioProject(project)
538
- const byPath = (p: string) => files.find((f) => f.path === p)
539
-
540
- it('emits shared modules, a page per screen, and the shell', () => {
541
- expect(files.map((f) => f.path)).toEqual(
542
- expect.arrayContaining([
543
- 'src/lib/schemas.ts',
544
- 'src/lib/data.ts',
545
- 'src/routes/customers/+page.svelte',
546
- 'src/routes/orders/+page.svelte',
547
- 'src/routes/+layout.svelte',
548
- 'src/routes/+page.svelte',
549
- ]),
550
- )
551
- })
552
-
553
- it('composes a grid + form screen (grid, controller, edit modal, New button)', () => {
554
- const page = byPath('src/routes/customers/+page.svelte')!.contents
555
- expect(page).toContain('createServerDataSource')
556
- expect(page).toContain('<SvGrid')
557
- expect(page).toContain('onRowDoubleClick={(e) => (editing = e.row)}')
558
- expect(page).toContain('<SvGridEditPanel')
559
- expect(page).toContain('+ New Customer')
560
- // grid columns honor the block config (visible fields, in order)
561
- expect(page).toMatch(/const columns_grid_\d+ =/)
562
- })
563
-
564
- it('data.ts gives every row type its own `type` keyword (verbatimModuleSyntax-safe)', () => {
565
- // SvelteKit enables verbatimModuleSyntax; `type A, B` (B unmarked) is an error.
566
- const dataTs = byPath('src/lib/data.ts')!.contents
567
- const importLine = dataTs.split('\n').find((l) => l.includes("from './schemas'"))!
568
- expect(importLine).toMatch(/\btype Customers\b/)
569
- expect(importLine).toMatch(/\btype Orders\b/) // would be a bare `Orders` before the fix
570
- })
571
-
572
- it('matches the data.ts lookup var name for a snake_case relation field', () => {
573
- const page = byPath('src/routes/orders/+page.svelte')!.contents
574
- const dataTs = byPath('src/lib/data.ts')!.contents
575
- // orders.customer_id -> ordersCustomerIdLookup in both places
576
- expect(page).toContain('ordersCustomerIdLookup')
577
- expect(dataTs).toContain('ordersCustomerIdLookup')
578
- })
579
-
580
- it('emits chart / dashboard / kpi blocks bound to allRows', () => {
581
- let p = createProject([customers])
582
- const sid = p.screens[0]!.id
583
- p = addBlock(p, sid, 'chart')
584
- p = addBlock(p, sid, 'dashboard')
585
- p = addBlock(p, sid, 'kpi')
586
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
587
- expect(page).toContain('<SvSchemaChart')
588
- expect(page).toContain('<SvSchemaDashboard')
589
- expect(page).toContain('reduceValue(allRows,')
590
- expect(page).toContain('async function loadAll()')
591
- })
592
-
593
- it('Gauge block: emits SvGauge bound to a reduced measure over allRows', () => {
594
- let p = createProject([customers])
595
- const sid = p.screens[0]!.id
596
- p = addBlock(p, sid, 'gauge')
597
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
598
- expect(page).toMatch(/import \{[^}]*SvGauge[^}]*\} from '@svgrid\/grid'/)
599
- expect(page).toContain('<SvGauge value={reduceValue(allRows,')
600
- expect(page).toContain('min={0} max={100}')
601
- expect(page).toContain('.gaugecard')
602
- })
603
-
604
- it('Tree block: folds rows into SvTree nodes by a self-referential parent', () => {
605
- // A category entity that references itself via parent_id.
606
- const categories: EntitySchema = {
607
- name: 'categories', label: 'Category', idField: 'id',
608
- fields: [
609
- { field: 'id', type: 'text', primaryKey: true },
610
- { field: 'name', type: 'text' },
611
- { field: 'parent_id', type: 'relation', relation: { entity: 'categories', labelField: 'name' } },
612
- ],
613
- }
614
- let p = createProject([categories])
615
- const sid = p.screens[0]!.id
616
- p = addBlock(p, sid, 'tree')
617
- const files = emitStudioProject(p)
618
- const page = files.find((f) => f.path === 'src/routes/categories/+page.svelte')!.contents
619
- expect(page).toMatch(/import \{[^}]*SvTree[^}]*\} from '@svgrid\/grid'/)
620
- expect(page).toContain('function toTreeNodes(')
621
- expect(page).toContain('<SvTree nodes={toTreeNodes(allRows')
622
- // Default config picked the self-referential FK as the parent link.
623
- expect(page).toContain('"name", "parent_id"')
624
- // The configured tree page (SvTree markup + helper) compiles.
625
- expect(() => compile(page, { filename: 'categories/+page.svelte', generate: 'client' })).not.toThrow()
626
- })
627
-
628
- it('Tabs block: nests display blocks into SvTabs panels bound to allRows', () => {
629
- let p = createProject([customers])
630
- const sid = p.screens[0]!.id
631
- p = addBlock(p, sid, 'tabs')
632
- const tb = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'tabs')!
633
- // Nest a chart in the first tab and a KPI in the second.
634
- let cfg = addTabBlock(tb.config as TabsConfig, 0, 'chart', customers)
635
- cfg = addTabBlock(cfg, 1, 'kpi', customers)
636
- p = updateBlock(p, sid, tb.id, { config: cfg })
637
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
638
- expect(page).toMatch(/import \{[^}]*SvTabs[^}]*\} from '@svgrid\/grid'/)
639
- expect(page).toContain('{#snippet panel(id)}')
640
- expect(page).toContain('<SvSchemaChart') // chart child in tab 1
641
- expect(page).toContain('reduceValue(allRows,') // kpi child in tab 2 uses allRows
642
- expect(page).toMatch(/let activeTab_\w+ = \$state\(/) // one active-tab state var
643
- expect(page).toContain('async function loadAll()') // nested aggs still load allRows
644
- expect(() => compile(page, { filename: 'customers/+page.svelte', generate: 'client' })).not.toThrow()
645
- })
646
-
647
- it('Accordion block: nests display blocks + components into SvAccordion sections', () => {
648
- let p = createProject([customers])
649
- const sid = p.screens[0]!.id
650
- p = addBlock(p, sid, 'accordion')
651
- const ab = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'accordion')!
652
- let cfg = addAccordionBlock(ab.config as AccordionConfig, 0, 'chart', customers) // display child
653
- cfg = addAccordionComponent(cfg, 1, 'badge') // component child
654
- p = updateBlock(p, sid, ab.id, { config: cfg })
655
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
656
- expect(page).toMatch(/import \{[^}]*SvAccordion[^}]*\} from '@svgrid\/grid'/)
657
- expect(page).toMatch(/import \{[^}]*SvBadge[^}]*\} from '@svgrid\/grid'/) // nested component imported
658
- expect(page).toContain('{#snippet panel(item)}')
659
- expect(page).toContain('expandMode="single"')
660
- expect(page).toContain('<SvSchemaChart') // display child renders
661
- expect(page).toContain('<SvBadge') // component child renders
662
- expect(page).toMatch(/let accOpen_\w+ = \$state<string\[\]>\(\['/) // expanded-ids state var
663
- expect(() => compile(page, { filename: 'customers/+page.svelte', generate: 'client' })).not.toThrow()
664
- })
665
-
666
- it('a chart-only screen has no controller/edit modal', () => {
667
- let p = createProject([customers])
668
- const sid = p.screens[0]!.id
669
- // remove the default grid + form, leave a chart
670
- p = { ...p, screens: [{ ...p.screens[0]!, blocks: [] }] }
671
- p = addBlock(p, sid, 'chart')
672
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
673
- expect(page).not.toContain('createServerDataSource')
674
- expect(page).not.toContain('SvGridEditPanel')
675
- expect(page).toContain('<SvSchemaChart')
676
- })
677
-
678
- it('honors a custom screen route + title', () => {
679
- const renamed = updateScreen(project, project.screens[0]!.id, { route: 'people', title: 'People' })
680
- const page = emitStudioProject(renamed).find((f) => f.path === 'src/routes/people/+page.svelte')!
681
- expect(page.contents).toContain('<h1 class="st__title">People</h1>')
682
- })
683
-
684
- it('every emitted .svelte compiles', () => {
685
- // Cover a screen with every block kind, with master-detail fully configured.
686
- let p = createProject([customers, orders])
687
- const sid = p.screens[0]!.id
688
- for (const k of ['chart', 'dashboard', 'kpi', 'gauge', 'tree', 'tabs', 'master-detail', 'lookup', 'pivot', 'filter', 'record'] as const) p = addBlock(p, sid, k)
689
- const mdId = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'master-detail')!.id
690
- p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
691
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
692
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
693
- }
694
- })
695
-
696
- it('emits a real SvGridMasterDetail when the block has a child entity + foreign key', () => {
697
- // A customers screen with a master-detail into orders, linked by orders.customer_id.
698
- let p = createProject([customers, orders])
699
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
700
- p = addBlock(p, sid, 'master-detail')
701
- const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
702
- p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
703
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
704
-
705
- expect(page).toContain('<SvGridMasterDetail')
706
- expect(page).toContain('SvGridMasterDetail } from') // imported (part of the @svgrid/enterprise import)
707
- expect(page).toContain('detailSchema={ordersSchema}')
708
- expect(page).toContain('load_md_orders_rows()') // child rows loaded in full
709
- expect(page).toContain("['customer_id']") // getChildren keys on the FK
710
- expect(page).toContain('async function loadAll()') // parent rows for the master grid
711
- // child schema + source come from the shared modules
712
- expect(page).toMatch(/import \{[^}]*\bordersSchema\b/)
713
- expect(page).toMatch(/import \{[^}]*\bordersSource\b/)
714
- })
715
-
716
- it('reloads allRows in save() so the master grid is not stale after an edit (no chart/kpi needed)', () => {
717
- // A default customers screen (grid + form) plus a master-detail into orders.
718
- let p = createProject([customers, orders])
719
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
720
- p = addBlock(p, sid, 'master-detail')
721
- const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
722
- p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
723
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
724
- expect(page).toMatch(/editing = undefined\s*\n\s*await loadAll\(\)/)
725
- })
726
-
727
- it('a self-referential master-detail emits no duplicate imports and compiles', () => {
728
- const employees: EntitySchema = {
729
- name: 'employees', label: 'Employee', idField: 'id',
730
- fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'managerId', type: 'text' }],
731
- }
732
- let p = createProject([employees])
733
- const sid = p.screens[0]!.id
734
- p = addBlock(p, sid, 'master-detail')
735
- const mdId = p.screens[0]!.blocks.at(-1)!.id
736
- p = updateBlock(p, sid, mdId, { config: { childEntity: 'employees', foreignKey: 'managerId' } as Partial<MasterDetailConfig> })
737
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/employees/+page.svelte')!.contents
738
- const importLine = page.split('\n').find((l) => l.includes("from '$lib/schemas'"))!
739
- expect(importLine.match(/employeesSchema/g)!).toHaveLength(1) // not duplicated
740
- expect(importLine.match(/\bEmployees\b/g)!).toHaveLength(1) // pascal(employees) = Employees
741
- expect(() => compile(page, { filename: 'employees.svelte', generate: 'client' })).not.toThrow()
742
- })
743
-
744
- it('leaves a placeholder (and still compiles) when master-detail is unconfigured', () => {
745
- let p = createProject([customers, orders])
746
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
747
- p = addBlock(p, sid, 'master-detail')
748
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
749
- expect(page).toContain('<!-- master-detail: set a child entity')
750
- expect(page).not.toContain('<SvGridMasterDetail')
751
- expect(() => compile(page, { filename: 'md.svelte', generate: 'client' })).not.toThrow()
752
- })
753
-
754
- it('emits a pivot block (SvPivotDesigner + fields/layout, loads all rows)', () => {
755
- let p = createProject([customers])
756
- const sid = p.screens[0]!.id
757
- p = addBlock(p, sid, 'pivot')
758
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
759
- expect(page).toContain('<SvPivotDesigner')
760
- expect(page).toMatch(/import \{[^}]*\bSvPivotDesigner\b/)
761
- expect(page).toContain('async function loadAll()') // pivot reads the whole table
762
- expect(page).toContain("kind: 'measure', defaultAgg: 'sum'") // mrr becomes a measure
763
- expect(page).toContain("agg: 'sum'")
764
- })
765
-
766
- it('emits a filter panel that drives the grid controller', () => {
767
- let p = createProject([customers])
768
- const sid = p.screens[0]!.id
769
- p = addBlock(p, sid, 'filter') // seeds tier (enum) + name (text) facets
770
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
771
- expect(page).toContain('class="st-filter"')
772
- expect(page).toContain('controller.setFilter({ columns: c })')
773
- expect(page).toContain("operator: 'equals'") // enum facet
774
- expect(page).toContain("operator: 'contains'") // text facet
775
- })
776
-
777
- it('renders an error banner (with retry) + a friendly grid empty message', () => {
778
- const page = emitStudioProject(createProject([customers])).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
779
- expect(page).toContain('{#if view.error}') // no longer a silent empty grid
780
- expect(page).toContain('class="st-error"')
781
- expect(page).toContain('controller.refresh()') // retry
782
- expect(page).toContain('emptyMessage="No customer yet."') // friendly empty state
783
- })
784
-
785
- it('emits a record panel wired to the grid selection', () => {
786
- let p = createProject([customers])
787
- const sid = p.screens[0]!.id
788
- p = addBlock(p, sid, 'record') // read-only by default
789
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
790
- expect(page).toContain('let selectedRecord = $state')
791
- expect(page).toContain('onRowClick={(e) => (selectedRecord = e.row)}') // grid publishes selection
792
- expect(page).toContain('<dl class="st-record">') // read-only field list
793
- })
794
-
795
- it('record panel honors its presentation (inline / modal / drawer)', () => {
796
- const pageFor = (presentation: 'inline' | 'modal' | 'drawer') => {
797
- let p = createProject([customers])
798
- const sid = p.screens[0]!.id
799
- p = addBlock(p, sid, 'record')
800
- const rid = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'record')!.id
801
- p = updateBlock(p, sid, rid, { config: { editable: true, presentation } as Partial<import('./project').RecordConfig> })
802
- return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
803
- }
804
- expect(pageFor('inline')).toContain('<SvGridEditPanel schema={customersSchema} row={selectedRecord} presentation="inline"')
805
- expect(pageFor('modal')).toContain('presentation="modal"')
806
- expect(pageFor('drawer')).toContain('presentation="drawer"')
807
- // Modal/drawer only mount while a row is selected (float over the page).
808
- expect(pageFor('modal')).toContain('Select a row to open its edit dialog')
809
- expect(pageFor('drawer')).toContain('Select a row to open its editor drawer')
810
- })
811
-
812
- it('RBAC: emits access.ts, gates the UI, guards the server route + nav, and compiles', () => {
813
- let p = createProject([customers, orders])
814
- // Both entities get a +server.ts, so we can prove each route's authorize call
815
- // is bound to ITS OWN entity's screen id, not a shared/blanket value.
816
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
817
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
818
- p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
819
- { role: 'admin', screens: '*', actions: '*' },
820
- { role: 'viewer', screens: ['customers'], actions: [] },
821
- ] } }
822
- const files = emitStudioProject(p)
823
-
824
- const access = files.find((f) => f.path === 'src/lib/access.ts')!.contents
825
- expect(access).toContain('export type AppRole = "admin" | "viewer"')
826
- expect(access).toContain('export const currentRole = writable<AppRole>("viewer")')
827
- expect(access).toContain('export function authorizeAction')
828
- expect(access).toContain('export function getServerRole')
829
- // Reads are gated by screen access now (server-enforced), not blanket-allowed:
830
- // an entity with no bound screen (e.g. a lookup-only relation target) stays
831
- // open since there's nothing to gate it by.
832
- expect(access).toContain("if (action !== 'read') return can(role, action)")
833
- expect(access).toContain('if (screenIds.length === 0) return true')
834
- expect(access).toContain('return screenIds.some((id) => canScreen(role, id))')
835
-
836
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
837
- expect(page).toContain("import { currentRole, can } from '$lib/access'")
838
- expect(page).toContain("{#if can($currentRole, 'create')}") // New button gated
839
- expect(page).toContain("if (can($currentRole, 'update'))") // edit gated
840
-
841
- // Each entity's route carries its OWN screen id(s) - not a shared/static value.
842
- const customersRoute = files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
843
- expect(customersRoute).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ["customers"])')
844
- const ordersRoute = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
845
- expect(ordersRoute).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ["orders"])')
846
-
847
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
848
- expect(layout).toContain('canScreen($currentRole, item.id)') // nav hides forbidden screens
849
-
850
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
851
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
852
- }
853
- })
854
-
855
- it('emits no access.ts and no gating when RBAC is off', () => {
856
- const files = emitStudioProject(createProject([customers]))
857
- expect(files.find((f) => f.path === 'src/lib/access.ts')).toBeUndefined()
858
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
859
- expect(page).not.toContain('$lib/access')
860
- expect(page).not.toContain('$currentRole')
861
- })
862
-
863
- it('Auth: scaffolds session + hooks + login + seed users, closes the RBAC loop, and compiles', () => {
864
- let p = createProject([customers])
865
- p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
866
- { role: 'admin', screens: '*', actions: '*' },
867
- { role: 'viewer', screens: ['customers'], actions: [] },
868
- ] } }
869
- p = setAuth(p, { enabled: true })
870
- const files = emitStudioProject(p)
871
- const get = (path: string) => files.find((f) => f.path === path)?.contents
872
-
873
- // The whole starter is emitted.
874
- for (const path of ['src/lib/server/auth.ts', 'src/lib/server/users.ts', 'src/hooks.server.ts', 'src/auth.d.ts', 'src/routes/+layout.server.ts', 'src/routes/login/+page.svelte', 'src/routes/login/+page.server.ts', 'src/routes/logout/+page.server.ts']) {
875
- expect(files.find((f) => f.path === path), path).toBeTruthy()
876
- }
877
- // SESSION_SECRET merges into the single shared .env.example (bundle level).
878
- const envEx = emitStudioAppBundle(p).filter((f) => f.path === '.env.example')
879
- expect(envEx).toHaveLength(1)
880
- expect(envEx[0]!.contents).toContain('SESSION_SECRET')
881
- // hooks resolves the session into event.locals.role - the value getServerRole reads.
882
- expect(get('src/hooks.server.ts')).toContain('event.locals.role = user?.role')
883
- // Dependency-free crypto (no external auth lib).
884
- expect(get('src/lib/server/auth.ts')).toContain('crypto.subtle')
885
- expect(get('src/lib/server/auth.ts')).toContain('export async function signSession')
886
- // One demo user per RBAC role.
887
- expect(get('src/lib/server/users.ts')).toContain('admin@example.com')
888
- expect(get('src/lib/server/users.ts')).toContain('viewer@example.com')
889
- // Protect-by-default guards the app in the root server load.
890
- expect(get('src/routes/+layout.server.ts')).toContain("throw redirect(302, '/login?redirectTo='")
891
- // Login form action signs + sets the session cookie.
892
- expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
893
- // The shell wires the session in: login bypass, real user, sign-out, role seed.
894
- const layout = get('src/routes/+layout.svelte')!
895
- expect(layout).toContain('["/login"].includes(page.url.pathname)') // login renders bare (no shell)
896
- expect(layout).toContain('action="/logout"')
897
- expect(layout).toContain('currentRole.set(data.role')
898
- expect(layout).toContain('data?.user?.email')
899
-
900
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
901
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
902
- }
903
- })
904
-
905
- // A SQL entity with a NUMBER primary key + a db-column alias, to exercise the
906
- // serial / autoincrement + dbColumn mapping paths.
907
- const products: EntitySchema = {
908
- name: 'products', label: 'Product', idField: 'id',
909
- fields: [
910
- { field: 'id', type: 'number', primaryKey: true },
911
- { field: 'title', type: 'text' },
912
- { field: 'price', type: 'number' },
913
- { field: 'inStock', type: 'boolean', dbColumn: 'in_stock' },
914
- ],
915
- }
916
-
917
- it('Data layer: Drizzle schema + typed repos + migrations config for SQL entities', () => {
918
- let p = createProject([products])
919
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products_tbl', dialect: 'postgres' })
920
- p = setDataLayer(p, true)
921
- const files = emitStudioProject(p)
922
- const get = (path: string) => files.find((f) => f.path === path)?.contents
923
-
924
- for (const path of ['src/lib/server/db/schema.ts', 'src/lib/server/db/index.ts', 'src/lib/server/db/products.ts', 'drizzle.config.ts']) {
925
- expect(files.find((f) => f.path === path), path).toBeTruthy()
926
- }
927
- const schema = get('src/lib/server/db/schema.ts')!
928
- expect(schema).toContain("from 'drizzle-orm/pg-core'")
929
- expect(schema).toContain('pgTable("products_tbl"') // real DB table name
930
- expect(schema).toContain('"id": serial("id").primaryKey()') // number PK -> serial
931
- expect(schema).toContain('"inStock": boolean("in_stock")') // dbColumn alias
932
- expect(schema).toContain('typeof products.$inferSelect')
933
- // A typed repo with returning-based CRUD, id typed from the PK.
934
- const repo = get('src/lib/server/db/products.ts')!
935
- expect(repo).toContain('export const productsRepo')
936
- expect(repo).toContain('.returning()).at(0)!')
937
- expect(repo).toContain('get: async (id: number)')
938
- // drizzle-kit migration config + client.
939
- expect(get('drizzle.config.ts')).toContain("dialect: 'postgresql'")
940
- expect(get('src/lib/server/db/index.ts')).toContain('drizzle-orm/node-postgres')
941
- })
942
-
943
- it('Data layer: package.json wires drizzle deps + migration scripts; SQLite maps a number id to autoincrement', () => {
944
- let p = createProject([products])
945
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'sqlite' })
946
- p = setDataLayer(p, true)
947
- const files = emitStudioAppBundle(p)
948
- const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
949
- expect(pkg.dependencies['drizzle-orm']).toBeTruthy()
950
- expect(pkg.devDependencies['drizzle-kit']).toBeTruthy()
951
- expect(pkg.scripts['db:generate']).toBe('drizzle-kit generate')
952
- expect(pkg.scripts['db:migrate']).toBe('drizzle-kit migrate')
953
- const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
954
- expect(schema).toContain("from 'drizzle-orm/sqlite-core'")
955
- expect(schema).toContain('integer("id").primaryKey({ autoIncrement: true })')
956
- })
957
-
958
- it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
959
- // Off by default.
960
- const sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
961
- expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
962
- // Toggle on but no SQL entity (memory source) -> nothing.
963
- expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
964
- // Toggle on + MSSQL (Drizzle has no SQL Server driver) -> raw route only, no Drizzle layer.
965
- const mssql = setDataLayer(setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'mssql' }), true)
966
- expect(emitStudioProject(mssql).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
967
- })
968
-
969
- it('Data layer: MySQL emits a re-select repo (no RETURNING) + int autoincrement PK', () => {
970
- let p = createProject([products])
971
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'mysql' })
972
- p = setDataLayer(p, true)
973
- const files = emitStudioProject(p)
974
- const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
975
- expect(schema).toContain("from 'drizzle-orm/mysql-core'")
976
- expect(schema).toContain('int("id").autoincrement().primaryKey()')
977
- const repo = files.find((f) => f.path === 'src/lib/server/db/products.ts')!.contents
978
- expect(repo).toContain('async function getById') // re-select helper
979
- expect(repo).toContain('.insertId') // uses the insert id
980
- expect(repo).not.toContain('.returning()') // MySQL has none
981
- })
982
-
983
- it('DB-backed auth: auth + data layer moves the user store into an auth_users table (hashed)', () => {
984
- let p = createProject([products])
985
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
986
- p = setDataLayer(p, true)
987
- p = setAuth(p, { enabled: true })
988
- const files = emitStudioProject(p)
989
- // The auth_users table lives in the same Drizzle schema (one migration covers it).
990
- expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('authUsers = pgTable("auth_users"')
991
- // users.ts queries the DB + seeds once; login verifies the PBKDF2 hash.
992
- const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
993
- expect(users).toContain("from './db/schema'")
994
- expect(users).toContain('function ensureSeeded')
995
- expect(users).toContain('passwordHash: await hashPassword(u.password)')
996
- const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
997
- expect(login).toContain('const user = await findUser(email)')
998
- expect(login).toContain('await verifyPassword(password, user.passwordHash)')
999
- // Without the data layer, auth stays on the in-code demo store.
1000
- const inCode = emitStudioProject(setAuth(createProject([products]), { enabled: true }))
1001
- expect(inCode.find((f) => f.path === 'src/lib/server/users.ts')!.contents).toContain('export const USERS')
1002
- })
1003
-
1004
- it('Auth depth: register + password reset + change-password + admin user management (DB-backed)', () => {
1005
- let p = createProject([products])
1006
- p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
1007
- { role: 'admin', screens: '*', actions: '*' },
1008
- { role: 'viewer', screens: ['products'], actions: [] },
1009
- ] } }
1010
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
1011
- p = setDataLayer(p, true)
1012
- p = setAuth(p, { enabled: true, register: true, userAdmin: true })
1013
- const files = emitStudioProject(p)
1014
- const get = (path: string) => files.find((f) => f.path === path)?.contents
1015
-
1016
- // Change-password (any signed-in user), self-service sign-up + recovery, admin screen.
1017
- for (const path of ['src/routes/account/+page.server.ts', 'src/routes/register/+page.server.ts', 'src/routes/forgot-password/+page.server.ts', 'src/routes/reset-password/+page.server.ts', 'src/routes/users/+page.server.ts', 'src/routes/users/+page.svelte']) {
1018
- expect(files.find((f) => f.path === path), path).toBeTruthy()
1019
- }
1020
- // Registration assigns the (least-privileged) default role.
1021
- expect(get('src/routes/register/+page.server.ts')).toContain('role: "viewer"')
1022
- // Password recovery uses signed reset tokens + an email stub (no extra table).
1023
- expect(get('src/lib/server/auth.ts')).toContain('export async function signReset')
1024
- expect(get('src/lib/server/auth.ts')).toContain('export async function sendResetEmail')
1025
- // User admin is role-gated server-side (only a full-access role can manage users).
1026
- expect(get('src/routes/users/+page.server.ts')).toContain("canScreen(getServerRole(event), '__users__')")
1027
- // The DB store gained full CRUD.
1028
- const users = get('src/lib/server/users.ts')!
1029
- for (const fn of ['createUser', 'updatePassword', 'setUserRole', 'deleteUser', 'listUsers']) expect(users).toContain(`export async function ${fn}`)
1030
- // Nav gains the admin Users screen (canScreen-gated); the auth pages render bare.
1031
- const layout = get('src/routes/+layout.svelte')!
1032
- expect(layout).toContain('"/users"')
1033
- expect(layout).toContain('"/register"') // bare-render set includes the sign-up flow
1034
- expect(get('src/routes/+layout.server.ts')).toContain('const PUBLIC = new Set(["/login","/register","/forgot-password","/reset-password"])')
1035
- expect(get('src/routes/login/+page.svelte')).toContain('href="/register"')
1036
-
1037
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1038
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1039
- }
1040
- })
1041
-
1042
- it('Auth extras: OAuth (github/google/oidc) + email 2FA + real email, all DB-backed', () => {
1043
- let p = createProject([products])
1044
- p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: ['products'], actions: [] }] } }
1045
- p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
1046
- p = setDataLayer(p, true)
1047
- p = setAuth(p, { enabled: true, oauth: ['github', 'google', 'oidc'], twoFactor: true })
1048
- const files = emitStudioProject(p)
1049
- const bundle = emitStudioAppBundle(p)
1050
- const get = (path: string) => files.find((f) => f.path === path)?.contents
1051
-
1052
- // OAuth: shared provider module + a [provider] start + callback endpoint.
1053
- for (const path of ['src/lib/server/oauth.ts', 'src/routes/auth/[provider]/+server.ts', 'src/routes/auth/[provider]/callback/+server.ts']) {
1054
- expect(files.find((f) => f.path === path), path).toBeTruthy()
1055
- }
1056
- expect(get('src/lib/server/oauth.ts')).toContain('export async function pkceChallenge') // PKCE
1057
- expect(get('src/lib/server/oauth.ts')).toContain('.well-known/openid-configuration') // OIDC discovery (Azure AD)
1058
- expect(get('src/routes/auth/[provider]/+server.ts')).toContain('code_challenge_method')
1059
- expect(get('src/routes/auth/[provider]/+server.ts')).toContain('new Set<Provider>(["github", "google", "oidc"])')
1060
- expect(get('src/routes/login/+page.svelte')).toContain('/auth/github')
1061
-
1062
- // Email 2FA: schema column, challenge helpers, login branch, verify route, account toggle.
1063
- expect(get('src/lib/server/db/schema.ts')).toContain('"twoFactor": boolean("two_factor").notNull().default(false)')
1064
- expect(get('src/lib/server/auth.ts')).toContain('export async function signChallenge')
1065
- expect(get('src/routes/login/+page.server.ts')).toContain('if (user.twoFactor)')
1066
- expect(files.find((f) => f.path === 'src/routes/login/verify/+page.server.ts')).toBeTruthy()
1067
- expect(get('src/lib/server/users.ts')).toContain('export async function setTwoFactor')
1068
-
1069
- // Real email layer (2FA implies it) + nodemailer wired only as an optional SMTP dep.
1070
- expect(get('src/lib/server/email.ts')).toContain('api.resend.com')
1071
- expect(get('src/lib/server/email.ts')).toContain("await import('nodemailer')")
1072
- const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
1073
- expect(pkg.dependencies.nodemailer).toBeTruthy()
1074
- expect(pkg.devDependencies['@types/nodemailer']).toBeTruthy()
1075
- // .env.example documents the OAuth + email vars.
1076
- const env = bundle.find((f) => f.path === '.env.example')!.contents
1077
- expect(env).toContain('GITHUB_CLIENT_ID')
1078
- expect(env).toContain('OIDC_ISSUER')
1079
- expect(env).toMatch(/RESEND_API_KEY|SMTP_HOST/)
1080
-
1081
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1082
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1083
- }
1084
- })
1085
-
1086
- it('Auth extras: OAuth / 2FA are inert without the DB-backed store', () => {
1087
- const p = setAuth(createProject([products]), { enabled: true, oauth: ['github'], twoFactor: true })
1088
- const files = emitStudioProject(p) // memory source -> no data layer
1089
- expect(files.find((f) => f.path === 'src/lib/server/oauth.ts')).toBeUndefined()
1090
- expect(files.find((f) => f.path === 'src/lib/server/email.ts')).toBeUndefined()
1091
- expect(files.find((f) => f.path === 'src/routes/login/verify/+page.server.ts')).toBeUndefined()
1092
- // Falls back to the in-code demo store + plain login.
1093
- expect(files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents).not.toContain('user.twoFactor')
1094
- })
1095
-
1096
- it('Auth depth: register/userAdmin are inert without the DB-backed store or RBAC', () => {
1097
- // register requested but no data layer -> no register routes (needs persistence).
1098
- const noDb = emitStudioProject(setAuth(createProject([products]), { enabled: true, register: true, userAdmin: true }))
1099
- expect(noDb.find((f) => f.path === 'src/routes/register/+page.server.ts')).toBeUndefined()
1100
- expect(noDb.find((f) => f.path === 'src/routes/users/+page.server.ts')).toBeUndefined()
1101
- // DB-backed but RBAC off -> register yes, userAdmin no (needs a role to gate on).
1102
- let p = setEntityDataSource(createProject([products]), 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
1103
- p = setAuth(setDataLayer(p, true), { enabled: true, register: true, userAdmin: true })
1104
- const files = emitStudioProject(p)
1105
- expect(files.find((f) => f.path === 'src/routes/register/+page.server.ts')).toBeTruthy()
1106
- expect(files.find((f) => f.path === 'src/routes/users/+page.server.ts')).toBeUndefined()
1107
- })
1108
-
1109
- it('no auth files unless auth.enabled; protect:false drops the route guard', () => {
1110
- // Off by default.
1111
- expect(emitStudioProject(createProject([customers])).find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
1112
- // protect:false still scaffolds login but does not force a redirect.
1113
- const p = setAuth(createProject([customers]), { enabled: true, protect: false })
1114
- const layoutServer = emitStudioProject(p).find((f) => f.path === 'src/routes/+layout.server.ts')!.contents
1115
- expect(layoutServer).not.toContain('throw redirect')
1116
- // Single admin seed user when RBAC is off.
1117
- const users = emitStudioProject(p).find((f) => f.path === 'src/lib/server/users.ts')!.contents
1118
- expect(users).toContain('admin@example.com')
1119
- expect(users).not.toContain('viewer@example.com')
1120
- })
1121
-
1122
- it('Supabase Auth: gates the app via SvAuthGate over the shared client, not the builtin session', () => {
1123
- let p = createProject([customers])
1124
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1125
- p = setAuth(p, { enabled: true, provider: 'supabase' })
1126
- const files = emitStudioProject(p)
1127
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
1128
- expect(layout).toContain('<SvAuthGate')
1129
- expect(layout).toContain("import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'")
1130
- expect(layout).toContain("import { supabaseClient } from '$lib/connections'")
1131
- // Shared client emitted; NO builtin cookie-session scaffold.
1132
- expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
1133
- expect(files.find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
1134
- expect(files.find((f) => f.path === 'src/lib/server/users.ts')).toBeUndefined()
1135
- expect(files.find((f) => f.path === 'src/routes/login/+page.svelte')).toBeUndefined()
1136
- // No cosmetic fake "Sign out" in the account menu - SvAuthGate's own bar owns it.
1137
- expect(layout).not.toMatch(/menuOpen = false\)}>Sign out/)
1138
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1139
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1140
- }
1141
- })
1142
-
1143
- it('Supabase Auth emits the shared client even with no Supabase-bound entity', () => {
1144
- let p = createProject([customers]) // stays in-memory
1145
- p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anon' } }
1146
- p = setAuth(p, { enabled: true, provider: 'supabase' })
1147
- const files = emitStudioProject(p)
1148
- expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
1149
- expect(files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents).toContain('<SvAuthGate')
1150
- // data.ts does NOT import the client (no Supabase data entity uses it).
1151
- expect(files.find((f) => f.path === 'src/lib/data.ts')!.contents).not.toContain("from './connections'")
1152
- })
1153
-
1154
- it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
1155
- let p = createProject([customers, orders])
1156
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1157
- p = { ...p, audit: true }
1158
- const files = emitStudioProject(p)
1159
-
1160
- expect(files.find((f) => f.path === 'src/lib/audit.ts')).toBeTruthy()
1161
- expect(files.find((f) => f.path === 'src/routes/api/audit/+server.ts')).toBeTruthy()
1162
- const viewer = files.find((f) => f.path === 'src/routes/audit/+page.svelte')!.contents
1163
- expect(viewer).toContain("endpoint: '/api/audit'")
1164
-
1165
- const route = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
1166
- expect(route).toContain("import { recordAudit } from '$lib/audit'")
1167
- expect(route).toContain('audit: (e) => recordAudit(')
1168
- expect(route).toContain('entity: "orders"')
1169
-
1170
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
1171
- expect(layout).toContain('Audit log') // nav gains the viewer link
1172
-
1173
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1174
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1175
- }
1176
- })
1177
-
1178
- it('emits no audit files without a SQL-bound entity (audit needs a server route)', () => {
1179
- const files = emitStudioProject({ ...createProject([customers]), audit: true })
1180
- expect(files.find((f) => f.path === 'src/lib/audit.ts')).toBeUndefined()
1181
- expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
1182
- })
1183
-
1184
- it('every registered toolbox component generates code that compiles', () => {
1185
- // The toolbox is registry-driven, so a new registration reaches codegen with
1186
- // no per-component work - which is exactly why it needs a blanket check that
1187
- // each one actually emits a valid import + usage.
1188
- for (const spec of UI_COMPONENT_REGISTRY) {
1189
- let p = createProject([customers])
1190
- const sid = p.screens[0]!.id
1191
- const defaults: Record<string, unknown> = {}
1192
- for (const pr of spec.props) if (pr.default != null) defaults[pr.key] = pr.default
1193
- if (spec.hasContent) defaults._content = spec.contentDefault ?? spec.label
1194
- p = addComponentBlock(p, sid, spec.key, defaults)
1195
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
1196
- expect(page.contents, `${spec.key} did not import ${spec.importName}`).toContain(spec.importName)
1197
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' }), `${spec.key} emitted invalid Svelte`).not.toThrow()
1198
- }
1199
- })
1200
-
1201
- describe('multi-tenancy', () => {
1202
- /** Tenancy needs auth (to know the tenant) + the typed layer (for the column). */
1203
- const tenantProject = () => {
1204
- let p = createProject([customers, orders])
1205
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1206
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1207
- p = setDataLayer(p, true)
1208
- p = setAuth(p, { enabled: true })
1209
- return setTenancy(p, { enabled: true })
1210
- }
1211
-
1212
- it('scopes every SQL route server-side', () => {
1213
- const files = emitStudioProject(tenantProject())
1214
- for (const entity of ['customers', 'orders']) {
1215
- const route = files.find((f) => f.path === `src/routes/api/${entity}/+server.ts`)!.contents
1216
- expect(route, entity).toContain("import { requireTenant } from '$lib/server/tenant'")
1217
- expect(route, entity).toContain("scope: ({ event }) => ({ field: \"tenantId\", value: requireTenant(event) })")
1218
- }
1219
- })
1220
-
1221
- it('resolves the tenant from the session and fails closed', () => {
1222
- const files = emitStudioProject(tenantProject())
1223
- const tenant = files.find((f) => f.path === 'src/lib/server/tenant.ts')!.contents
1224
- // requireTenant THROWS rather than returning null - the transport turns that
1225
- // into a 403. Returning null would run the query unscoped.
1226
- expect(tenant).toContain('export function requireTenant')
1227
- expect(tenant).toContain('throw new Error')
1228
- // The tenant comes off the session in hooks, never from the client.
1229
- const hooks = files.find((f) => f.path === 'src/hooks.server.ts')!.contents
1230
- expect(hooks).toContain('event.locals.tenantId')
1231
- })
1232
-
1233
- it('adds the scoping column to every scoped table + the user store', () => {
1234
- const schema = emitStudioProject(tenantProject()).find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
1235
- // notNull: a row with no tenant belongs to nobody and no scoped read finds it.
1236
- expect(schema).toContain('"tenantId": text("tenant_id").notNull()')
1237
- // The user store carries it too - that is where the session's tenant comes from.
1238
- expect(schema.slice(schema.indexOf('authUsers'))).toContain('"tenantId"')
1239
- })
1240
-
1241
- it('leaves shared entities global (no column, no scope)', () => {
1242
- let p = tenantProject()
1243
- p = setTenancy(p, { enabled: true, sharedEntities: ['orders'] })
1244
- const files = emitStudioProject(p)
1245
- expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('scope:')
1246
- expect(files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents).not.toContain('scope:')
1247
- })
1248
-
1249
- it('honours a custom scoping column', () => {
1250
- let p = tenantProject()
1251
- p = setTenancy(p, { enabled: true, field: 'orgId' })
1252
- const files = emitStudioProject(p)
1253
- expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('field: "orgId"')
1254
- expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('"orgId": text("org_id").notNull()')
1255
- })
1256
-
1257
- it('degrades to OFF without auth or the typed data layer, rather than half-enforcing', () => {
1258
- // No auth: nothing can tell us who the tenant is.
1259
- let noAuth = createProject([customers])
1260
- noAuth = setEntityDataSource(noAuth, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1261
- noAuth = setDataLayer(noAuth, true)
1262
- noAuth = setTenancy(noAuth, { enabled: true })
1263
- expect(emitStudioProject(noAuth).find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1264
-
1265
- // No typed layer: the scoping column would not exist in any schema.
1266
- let noLayer = createProject([customers])
1267
- noLayer = setEntityDataSource(noLayer, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1268
- noLayer = setAuth(noLayer, { enabled: true })
1269
- noLayer = setTenancy(noLayer, { enabled: true })
1270
- const files = emitStudioProject(noLayer)
1271
- expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1272
- expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).not.toContain('scope:')
1273
- })
1274
-
1275
- it('emits nothing tenancy-related when it is off', () => {
1276
- const files = emitStudioProject(createProject([customers]))
1277
- expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1278
- })
1279
- })
1280
-
1281
- it('Scheduled jobs emit a guarded /api/cron + handler registry', () => {
1282
- let p = createProject([customers, orders])
1283
- p = setJob(p, 'nightly-digest', { name: 'Nightly digest', cron: '0 6 * * *', kind: 'code', code: "console.log('tick')" })
1284
- const files = emitStudioProject(p)
1285
-
1286
- const route = files.find((f) => f.path === 'src/routes/api/cron/+server.ts')!.contents
1287
- // Secret-guarded, and refuses to run when no secret is configured - an
1288
- // unguarded "do work" URL is the failure mode worth preventing.
1289
- expect(route).toContain('env.CRON_SECRET')
1290
- expect(route).toContain('if (!secret) return false')
1291
- expect(route).toContain("throw error(401")
1292
- // One failing job must not abort the rest of the run.
1293
- expect(route).toContain('try { await fn()')
1294
-
1295
- const jobs = files.find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1296
- expect(jobs).toContain('"nightly-digest"')
1297
- expect(jobs).toContain("console.log('tick')")
1298
- expect(jobs).toContain('export const enabledJobs: string[] = ["nightly-digest"]')
1299
- })
1300
-
1301
- it('a disabled job keeps its handler but drops out of the scheduled run', () => {
1302
- let p = createProject([customers])
1303
- p = setJob(p, 'a', { name: 'A', cron: '0 1 * * *', kind: 'code', code: 'void 0' })
1304
- p = setJob(p, 'b', { name: 'B', cron: '0 2 * * *', kind: 'code', code: 'void 0', enabled: false })
1305
- const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1306
- expect(jobs).toContain('"a"')
1307
- expect(jobs).toContain('"b"') // still callable via ?job=b
1308
- expect(jobs).toContain('export const enabledJobs: string[] = ["a"]')
1309
- })
1310
-
1311
- it('an email job without the email layer emits a warning slot, not a broken import', () => {
1312
- let p = createProject([customers])
1313
- p = setJob(p, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'email', entity: 'customers', to: 'ops@example.com' })
1314
- const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1315
- expect(jobs).not.toContain("from '$lib/server/email'")
1316
- expect(jobs).toContain('email is not enabled on this project')
1317
- })
1318
-
1319
- it('an email job with the email layer on sends a summary of its entity', () => {
1320
- let p = createProject([customers, orders])
1321
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1322
- p = setDataLayer(p, true)
1323
- p = setAuth(p, { enabled: true, email: true })
1324
- p = setJob(p, 'digest', { name: 'Daily orders', cron: '0 6 * * *', kind: 'email', entity: 'orders', to: 'ops@example.com', subject: 'Orders today' })
1325
- const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1326
- expect(jobs).toContain("import { sendEmail } from '$lib/server/email'")
1327
- expect(jobs).toContain("sendEmail('ops@example.com', 'Orders today'")
1328
- })
1329
-
1330
- it('no jobs emits no cron route at all', () => {
1331
- const files = emitStudioProject(createProject([customers]))
1332
- expect(files.find((f) => f.path === 'src/routes/api/cron/+server.ts')).toBeUndefined()
1333
- expect(files.find((f) => f.path === 'src/lib/server/jobs.ts')).toBeUndefined()
1334
- })
1335
-
1336
- it('Audit persists to an audit_log table when the typed data layer is on', () => {
1337
- let p = createProject([customers, orders])
1338
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1339
- p = { ...p, audit: true, dataLayer: 'drizzle' }
1340
- const files = emitStudioProject(p)
1341
-
1342
- // The trail gets a real table in the same schema, so one migration covers it.
1343
- const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
1344
- expect(schema).toContain('export const auditLog =')
1345
- expect(schema).toContain('"audit_log"')
1346
- // before / after snapshots - the in-memory store only kept field names.
1347
- expect(schema).toContain('"before"')
1348
- expect(schema).toContain('"after"')
1349
-
1350
- const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
1351
- expect(audit).toContain("from '$lib/server/db'")
1352
- expect(audit).toContain('db.insert(auditLog)')
1353
- // Paged in the database, and read-only (append-only through recordAudit).
1354
- expect(audit).toContain('async getRows(')
1355
- expect(audit).toContain('.limit(limit).offset(start)')
1356
- expect(audit).not.toContain('createInMemoryDataSource')
1357
- })
1358
-
1359
- it('Audit stays in-memory (and says so) without the typed data layer', () => {
1360
- let p = createProject([customers, orders])
1361
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1362
- p = { ...p, audit: true }
1363
- const files = emitStudioProject(p)
1364
- const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
1365
- expect(audit).toContain('createInMemoryDataSource')
1366
- expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
1367
- })
1368
-
1369
- it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
1370
- let p = createProject([customers, orders])
1371
- const cid = p.screens.find((s) => s.entity === 'customers')!.id
1372
- const gridId = p.screens.find((s) => s.id === cid)!.blocks[0]!.id
1373
- p = updateBlock(p, cid, gridId, { colSpan: 5 }) // finer than 1/2/3
1374
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1375
- expect(page).toContain('class="st-screen"') // 12-col grid lives in the responsive app.css class
1376
- expect(page).toContain('grid-column: span 5') // colSpan honored
1377
- // A legacy block with only `span` (default grid span 3) maps to 12 columns.
1378
- const ordPage = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
1379
- expect(ordPage).toContain('grid-column: span 12')
1380
- })
1381
-
1382
- it('No-code business logic: compiles formula -> computed and validations -> hooks.validate', () => {
1383
- const invoices: EntitySchema = {
1384
- name: 'invoices', label: 'Invoice', idField: 'id',
1385
- fields: [
1386
- { field: 'id', type: 'text', primaryKey: true },
1387
- { field: 'qty', type: 'number' },
1388
- { field: 'price', type: 'number' },
1389
- { field: 'total', type: 'number', formula: 'qty * price' },
1390
- ],
1391
- validations: [
1392
- { field: 'qty', op: 'gt', value: 0, message: 'Quantity must be positive' },
1393
- { field: 'price', op: 'gte', compareTo: 'qty', message: 'Price must be at least qty' },
1394
- ],
1395
- }
1396
- const files = emitStudioProject(createProject([invoices]))
1397
- const schemas = files.find((f) => f.path === 'src/lib/schemas.ts')!.contents
1398
- expect(schemas).toContain('.computed = (row) =>')
1399
- expect(schemas).toContain('$.qty * $.price') // field names resolved to the row
1400
- expect(schemas).toContain('.hooks = {')
1401
- expect(schemas).toContain('errors["qty"] = "Quantity must be positive"')
1402
- expect(schemas).toContain('Number($["price"]) >= Number($["qty"])') // cross-field compare
1403
- // The no-code specs are NOT left in the data literal.
1404
- expect(schemas).not.toContain('"formula"')
1405
- expect(schemas).not.toContain('"validations"')
1406
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1407
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1408
- }
1409
- })
1410
-
1411
- it('PGlite: a zero-setup embedded-Postgres entity generates a client DB + seed, no server route', () => {
1412
- let p = createProject([customers])
1413
- p = setEntityDataSource(p, 'customers', { kind: 'pglite', table: 'customers' })
1414
- const files = emitStudioProject(p)
1415
- const data = files.find((f) => f.path === 'src/lib/data.ts')!.contents
1416
- expect(data).toContain("import { PGlite } from '@electric-sql/pglite'")
1417
- expect(data).toContain("new PGlite('idb://svgrid-studio')")
1418
- expect(data).toContain('CREATE TABLE IF NOT EXISTS "customers"')
1419
- // Schema-additions migrate on reload (CREATE TABLE IF NOT EXISTS is a no-op once the table exists).
1420
- expect(data).toContain('ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "mrr" double precision')
1421
- expect(data).not.toMatch(/ADD COLUMN IF NOT EXISTS "id"/) // never re-add the primary key
1422
- expect(data).toContain('"mrr" double precision') // typed column
1423
- expect(data).toContain('createSqlDataSource<Customers>') // real SQL source
1424
- expect(data).toContain('await pgReady') // gated on bootstrap
1425
- expect(data).toContain('_pgSeed(') // seeded once
1426
- // Runs in the browser: NO server route.
1427
- expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeUndefined()
1428
- // The exported app declares the dependency.
1429
- const pkg = emitStudioAppBundle(p).find((f) => f.path === 'package.json')!.contents
1430
- expect(pkg).toContain('@electric-sql/pglite')
1431
- })
1432
-
1433
- it('PGlite: a curated seed on the source (e.g. an imported CSV) is what the local DB seeds', () => {
1434
- let p = createProject([customers])
1435
- const seed = [{ id: 7, name: 'Zaphod', email: 'z@beeble.brox', mrr: 999, active: true }]
1436
- p = setEntityDataSource(p, 'customers', { kind: 'pglite', table: 'customers', seed })
1437
- const data = emitStudioProject(p).find((f) => f.path === 'src/lib/data.ts')!.contents
1438
- expect(data).toContain('"Zaphod"') // the imported row, not the generator's
1439
- expect(data).toContain('_pgSeed("customers"')
1440
- })
1441
-
1442
- it('Deploy: default (auto) ships adapter-auto and no provider config', () => {
1443
- const bundle = emitStudioAppBundle(createProject([customers]))
1444
- const svelteCfg = bundle.find((f) => f.path === 'svelte.config.js')!.contents
1445
- const pkg = bundle.find((f) => f.path === 'package.json')!.contents
1446
- expect(svelteCfg).toContain("import adapter from '@sveltejs/adapter-auto'")
1447
- expect(pkg).toContain('@sveltejs/adapter-auto')
1448
- // No provider-specific config files in auto mode.
1449
- expect(bundle.find((f) => f.path === 'netlify.toml')).toBeUndefined()
1450
- expect(bundle.find((f) => f.path === 'wrangler.toml')).toBeUndefined()
1451
- })
1452
-
1453
- it('Deploy: picking a target swaps the adapter + config and the README deploy steps', () => {
1454
- const netlify = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'netlify'))
1455
- expect(netlify.find((f) => f.path === 'svelte.config.js')!.contents).toContain("'@sveltejs/adapter-netlify'")
1456
- expect(netlify.find((f) => f.path === 'package.json')!.contents).toContain('@sveltejs/adapter-netlify')
1457
- expect(netlify.find((f) => f.path === 'netlify.toml')!.contents).toContain('npm run build')
1458
- expect(netlify.find((f) => f.path === 'README.md')!.contents).toContain('netlify deploy')
1459
-
1460
- const cf = emitStudioAppBundle(setDeployTarget(createProject([customers], { title: 'My App' }), 'cloudflare'))
1461
- const wrangler = cf.find((f) => f.path === 'wrangler.toml')!.contents
1462
- expect(wrangler).toContain('name = "my-app"')
1463
- expect(wrangler).toContain('pages_build_output_dir = ".svelte-kit/cloudflare"')
1464
- expect(cf.find((f) => f.path === 'svelte.config.js')!.contents).toContain("'@sveltejs/adapter-cloudflare'")
1465
-
1466
- // The designer's Deploy panel reads the same facts.
1467
- const info = studioDeployInfo(setDeployTarget(createProject([customers]), 'vercel'))
1468
- expect(info).toMatchObject({ label: 'Vercel', cli: 'npx vercel --prod', dashboard: 'https://vercel.com/new' })
1469
- })
1470
-
1471
- it('Deploy: setDeployTarget(auto) clears the target (round-trips clean)', () => {
1472
- const p = setDeployTarget(setDeployTarget(createProject([customers]), 'vercel'), 'auto')
1473
- expect(p.deploy).toBeUndefined()
1474
- expect(parseProject(JSON.stringify(p)).deploy).toBeUndefined()
1475
- })
1476
-
1477
- it('Deploy pipeline: CI workflow always, a push-to-deploy workflow + DEPLOY.md + deploy script per target', () => {
1478
- const bundle = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
1479
- const get = (p: string) => bundle.find((f) => f.path === p)?.contents
1480
- // Universal CI (robust without a committed lockfile).
1481
- const ci = get('.github/workflows/ci.yml')!
1482
- expect(ci).toContain('npm install')
1483
- expect(ci).not.toContain('npm ci') // no lockfile is shipped
1484
- expect(ci).not.toContain('cache: npm')
1485
- expect(ci).toContain('npm run build')
1486
- // Push-to-deploy workflow, gated on the secret so an unconfigured repo stays green.
1487
- const deploy = get('.github/workflows/deploy.yml')!
1488
- expect(deploy).toContain("if: ${{ secrets.VERCEL_TOKEN != '' }}")
1489
- expect(deploy).toContain('vercel deploy --prebuilt --prod')
1490
- // Runbook + npm deploy script.
1491
- expect(get('DEPLOY.md')).toContain('VERCEL_TOKEN')
1492
- expect(JSON.parse(get('package.json')!).scripts.deploy).toBe('vercel deploy --prod')
1493
- // Designer panel exposes the required secrets.
1494
- expect(studioDeployInfo(setDeployTarget(createProject([customers]), 'vercel')).secrets).toEqual(['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'])
1495
- })
1496
-
1497
- it('Deploy pipeline: node target ships a Dockerfile + .dockerignore and no deploy workflow', () => {
1498
- const bundle = emitStudioAppBundle(setDeployTarget(createProject([customers], { title: 'My App' }), 'node'))
1499
- expect(bundle.find((f) => f.path === 'Dockerfile')!.contents).toContain('CMD ["node", "build"]')
1500
- expect(bundle.find((f) => f.path === '.dockerignore')).toBeTruthy()
1501
- expect(bundle.find((f) => f.path === '.github/workflows/deploy.yml')).toBeUndefined() // self-hosted: no push-deploy
1502
- expect(bundle.find((f) => f.path === '.github/workflows/ci.yml')).toBeTruthy() // ...but still CI
1503
- expect(bundle.find((f) => f.path === 'DEPLOY.md')!.contents).toContain('docker build')
1504
- })
1505
-
1506
- it('Deploy pipeline: a single .env.example merges DATABASE_URL + SESSION_SECRET (no duplicate)', () => {
1507
- let p = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1508
- p = setAuth(p, { enabled: true })
1509
- const envs = emitStudioAppBundle(p).filter((f) => f.path === '.env.example')
1510
- expect(envs).toHaveLength(1)
1511
- expect(envs[0]!.contents).toContain('DATABASE_URL')
1512
- expect(envs[0]!.contents).toContain('SESSION_SECRET')
1513
- })
1514
-
1515
- it('Round-trip: the exported bundle ships studio.config.json that re-parses into the designer', () => {
1516
- const p = setShell(createProject([customers, orders]), { brand: 'Acme', logo: 'data:image/png;base64,AA' })
1517
- const bundle = emitStudioAppBundle(p)
1518
- const cfg = bundle.find((f) => f.path === 'studio.config.json')
1519
- expect(cfg).toBeTruthy()
1520
- const reparsed = parseProject(cfg!.contents) // Load-able back into the designer
1521
- expect(reparsed.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
1522
- expect(reparsed.screens.length).toBe(p.screens.length)
1523
- expect(reparsed.theme?.shell?.logo).toBe('data:image/png;base64,AA') // logo survives the round-trip
1524
- expect(bundle.find((f) => f.path === 'README.md')!.contents).toContain('Round-tripping back into the designer')
1525
- })
1526
-
1527
- it('Shell: responsive sidebar (burger + drawer), a company logo, and a stacking screen grid', () => {
1528
- let p = createProject([customers])
1529
- p = setShell(p, { style: 'sidebar', brand: 'Acme', logo: 'data:image/png;base64,AAAA' })
1530
- const files = emitStudioProject(p)
1531
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
1532
- expect(layout).toContain('sv-app__mobilebar') // mobile bar
1533
- expect(layout).toContain('sv-app__burger') // hamburger toggle
1534
- expect(layout).toContain('let navOpen = $state(false)') // drawer state
1535
- expect(layout).toContain('sv-app__backdrop')
1536
- expect(layout).toContain('const logo = "data:image/png;base64,AAAA"')
1537
- expect(layout).toContain('<img class="sv-app__logo"') // logo rendered
1538
- // Collapsible sidebar: docks on desktop, collapses to a drawer on tablet/phone.
1539
- expect(layout).toContain('sv-app__collapse') // desktop collapse toggle
1540
- expect(layout).toContain('class:is-drawer={drawer}') // drawer class binding
1541
- expect(layout).toContain("matchMedia('(max-width: 1024px)')") // tablet/phone -> collapsed
1542
- expect(layout).toContain("localStorage.setItem('svapp:nav'") // collapse choice persists
1543
-
1544
- const appcss = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
1545
- expect(appcss).toContain('.st-screen')
1546
- expect(appcss).toContain('grid-template-columns: repeat(12, 1fr)')
1547
- expect(appcss).toContain('@media (max-width: 720px)') // blocks stack on mobile
1548
-
1549
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1550
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1551
- }
1552
- })
1553
-
1554
- it('Conditional formatting: compiles no-code rules into the grid rule engine', () => {
1555
- let p = createProject([customers])
1556
- const cid = p.screens[0]!.id
1557
- const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
1558
- p = updateBlock(p, cid, gridId, { config: { formatRules: [
1559
- { field: 'mrr', op: 'lt', value: 0, color: '#dc2626', bold: true },
1560
- { field: 'tier', op: 'eq', value: 'pro', background: '#eef2ff' },
1561
- ] } as Partial<GridConfig> })
1562
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1563
- expect(page).toContain('conditionalFormats={[')
1564
- expect(page).toContain("type: 'rule' as const")
1565
- expect(page).toContain("columns: ['mrr']")
1566
- expect(page).toContain('Number(value) < 0')
1567
- expect(page).toContain('fontWeight: 700')
1568
- expect(page).toContain("String(value) === \"pro\"")
1569
- expect(() => compile(page, { filename: 'cf.svelte', generate: 'client' })).not.toThrow()
1570
- })
1571
-
1572
- it('Navigation: drill-through, chart drill, URL filters, and row actions - and compiles', () => {
1573
- let p = createProject([customers, orders])
1574
- const cid = p.screens.find((s) => s.entity === 'customers')!.id
1575
- const gridId = p.screens.find((s) => s.id === cid)!.blocks.find((b) => b.config.kind === 'grid')!.id
1576
- p = updateBlock(p, cid, gridId, { config: {
1577
- rowLink: { screen: 'orders', sourceField: 'id', targetField: 'customer_id' },
1578
- rowActions: [{ kind: 'edit' }, { kind: 'delete' }, { kind: 'navigate', screen: 'orders', sourceField: 'id', targetField: 'customer_id', label: 'Orders' }],
1579
- } as Partial<GridConfig> })
1580
- const files = emitStudioProject(p)
1581
-
1582
- const cust = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1583
- expect(cust).toContain("import { goto } from '$app/navigation'")
1584
- expect(cust).toContain("onRowClick={(e) => goto('/orders?customer_id='") // drill-through on row click
1585
- expect(cust).toContain("id: '__actions'") // synthetic action column
1586
- expect(cust).toContain('renderSnippet(rowActions_')
1587
- expect(cust).toContain('{#snippet rowActions_')
1588
- expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
1589
-
1590
- const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
1591
- expect(ord).toContain("import { page } from '$app/state'") // drill TARGET reads URL params
1592
- expect(ord).toContain('sp.get(_f)')
1593
-
1594
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1595
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1596
- }
1597
- })
1598
-
1599
- it('i18n: emits the catalog, routes labels + headers through t(), and compiles', () => {
1600
- const p = { ...createProject([customers]), i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
1601
- const files = emitStudioProject(p)
1602
-
1603
- const cat = files.find((f) => f.path === 'src/lib/i18n.ts')!.contents
1604
- expect(cat).toContain('export type Locale = "en" | "es"')
1605
- expect(cat).toContain('export const currentLocale = writable<Locale>("en")')
1606
- expect(cat).toContain('export function localizeCols')
1607
- expect(cat).toContain('"col.customers.name"') // seeded from the schema
1608
- expect(cat).toContain('"nav.customers"')
1609
- // EVERY locale is seeded with the same keys, not just the default - an empty
1610
- // `{}` made the translator hunt for key names in the codegen.
1611
- expect(cat).not.toContain('"es": {}')
1612
- expect(cat).toMatch(/"es": \{[\s\S]*"col\.customers\.name"/)
1613
- expect(cat).toContain('// TODO: translate')
1614
- // Both locales carry the full key set.
1615
- const keyCount = (block: string) => (block.match(/"col\.customers\./g) ?? []).length
1616
- const [, enBlock = '', esBlock = ''] = cat.split(/"(?:en|es)": /)
1617
- expect(keyCount(enBlock)).toBe(keyCount(esBlock))
1618
-
1619
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1620
- expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
1621
- expect(page).toContain("$t('screen.customers'") // localized title
1622
- expect(page).toContain('localizeCols(') // localized column headers
1623
-
1624
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
1625
- expect(layout).toContain("import { t, currentLocale, locales } from '$lib/i18n'")
1626
- expect(layout).toContain('sv-app__locale') // the language switcher
1627
-
1628
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1629
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1630
- }
1631
- })
1632
-
1633
- it('i18n: KPI / Gauge / Tab labels route through t() (not raw literals)', () => {
1634
- let p: StudioProject = { ...createProject([customers]), i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
1635
- const sid = p.screens[0]!.id
1636
- p = addBlock(p, sid, 'kpi')
1637
- p = addBlock(p, sid, 'gauge')
1638
- p = addBlock(p, sid, 'tabs')
1639
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1640
- expect(page).toMatch(/\{\$t\('block\.kpi-[^']+', /) // KPI label localized
1641
- expect(page).toMatch(/\{\$t\('block\.gauge-[^']+', /) // Gauge label localized
1642
- expect(page).toMatch(/label: \$t\('tab\.tabs-[^']+'/) // Tab labels localized in the tabs array
1643
- // Without i18n the same labels stay raw literals (no $t wrapper).
1644
- let plain = createProject([customers])
1645
- plain = addBlock(plain, plain.screens[0]!.id, 'kpi')
1646
- const plainPage = emitStudioProject(plain).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1647
- expect(plainPage).not.toContain("$t('block.")
1648
- })
1649
-
1650
- it('throws on a screen pointing at a missing entity', () => {
1651
- const broken = { ...project, screens: [{ ...project.screens[0]!, entity: 'ghosts' }] }
1652
- expect(() => emitStudioProject(broken)).toThrow(/missing entity/)
1653
- })
1654
- })
1655
-
1656
- describe('Custom actions', () => {
1657
- it('a toolbar action on an entity-bound screen emits a wired handler + button + stub route, and compiles', () => {
1658
- let p = createProject([customers])
1659
- const sid = p.screens[0]!.id
1660
- p = addScreenAction(p, sid, { label: 'Sync now', icon: '\u{1F504}', confirm: 'Sync now?' })
1661
- const actionId = p.screens[0]!.actions![0]!.id
1662
- const files = emitStudioProject(p)
1663
-
1664
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1665
- expect(page).toContain(`let actionBusy_${actionId.replace(/-/g, '_')} = $state(false)`)
1666
- expect(page).toContain(`fetch('/api/actions/${actionId}'`)
1667
- expect(page).toContain('Sync now')
1668
- expect(page).toContain(`confirm('Sync now?')`)
1669
-
1670
- const route = files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)!.contents
1671
- expect(route).toContain('export async function POST')
1672
- expect(route).toContain('TODO: your business logic here')
1673
-
1674
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1675
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1676
- }
1677
- })
1678
-
1679
- it('a row-level custom action renders per row, wired to the same stub route, and compiles', () => {
1680
- let p = createProject([customers])
1681
- const sid = p.screens[0]!.id
1682
- const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
1683
- p = addScreenAction(p, sid, { label: 'Resend invoice' })
1684
- const actionId = p.screens[0]!.actions![0]!.id
1685
- p = updateScreen(p, sid, { actions: [] }) // the action now lives only on the row, not the toolbar
1686
- p = updateBlock(p, sid, gridId, { config: {
1687
- rowActions: [{ kind: 'custom', id: actionId, label: 'Resend invoice' }],
1688
- } as Partial<GridConfig> })
1689
- const files = emitStudioProject(p)
1690
-
1691
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1692
- expect(page).toContain("id: '__actions'") // synthetic action column
1693
- expect(page).toContain(`runAction_${actionId.replace(/-/g, '_')}({ id: `)
1694
- expect(page).toContain('Resend invoice')
1695
-
1696
- expect(files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)).toBeTruthy()
1697
-
1698
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1699
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1700
- }
1701
- })
1702
-
1703
- it('a freestanding screen (no entity) renders a toolbar action and no grid/data plumbing, and compiles', () => {
1704
- let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
1705
- const sid = p.screens.find((s) => s.title === 'Reports')!.id
1706
- p = addScreenAction(p, sid, { label: 'Run report' })
1707
- const actionId = p.screens.find((s) => s.id === sid)!.actions![0]!.id
1708
- const files = emitStudioProject(p)
1709
-
1710
- const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1711
- expect(page).toContain('Run report')
1712
- expect(page).toContain(`fetch('/api/actions/${actionId}'`)
1713
- expect(page).not.toContain('createServerDataSource')
1714
- expect(page).not.toContain('<SvGrid')
1715
-
1716
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1717
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1718
- }
1719
- })
1720
-
1721
- it('RBAC-gated action: the toolbar button and the stub route both check canScreen', () => {
1722
- let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
1723
- const sid = p.screens.find((s) => s.title === 'Reports')!.id
1724
- p = addScreenAction(p, sid, { label: 'Run report' })
1725
- const actionId = p.screens.find((s) => s.id === sid)!.actions![0]!.id
1726
- p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
1727
- { role: 'admin', screens: '*', actions: '*' },
1728
- { role: 'viewer', screens: [], actions: [] },
1729
- ] } }
1730
- const files = emitStudioProject(p)
1731
-
1732
- const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1733
- expect(page).toContain(`{#if canScreen($currentRole, '${sid}')}`)
1734
-
1735
- const route = files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)!.contents
1736
- expect(route).toContain("import { getServerRole, canScreen } from '$lib/access'")
1737
- expect(route).toContain(`if (!canScreen(getServerRole(event), '${sid}'))`)
1738
- expect(route).toContain('status: 403')
1739
-
1740
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1741
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1742
- }
1743
- })
1744
- })
1745
-
1746
- describe('value-driven form fields (the form builder)', () => {
1747
- const cmp = (column: string, op: string, value: unknown) => ({ kind: 'cmp', column, op, value })
1748
- const conditional: EntitySchema = {
1749
- name: 'returns',
1750
- idField: 'id',
1751
- fields: [
1752
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
1753
- { field: 'mode', type: 'text' },
1754
- { field: 'justification', type: 'text', when: { visible: cmp('mode', 'equals', 'refund') as never } },
1755
- ],
1756
- }
1757
-
1758
- it('carries the conditions into the generated schema, so the app behaves the same', () => {
1759
- // They are plain data, so they survive the JSON round-trip into schemas.ts
1760
- // with no compilation step - the reason conditions are a PredicateExpr
1761
- // rather than a function.
1762
- const files = emitStudioProject(createProject([conditional]))
1763
- const schemas = files.find((f) => f.path === 'src/lib/schemas.ts')!.contents
1764
- expect(schemas).toContain('"when"')
1765
- expect(schemas).toMatch(/"column": "mode"/)
1766
- expect(schemas).toMatch(/"op": "equals"/)
1767
- })
1768
-
1769
- it('makes an SSR route drop values for fields the form was hiding', () => {
1770
- let p = createProject([conditional])
1771
- p = { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
1772
- const server = emitStudioProject(p).find((f) => f.path === 'src/routes/returns/+page.server.ts')
1773
- if (!server) return // shape not SSR-eligible in this project; covered elsewhere
1774
- expect(server.contents).toMatch(/import \{ validateAll, stripHiddenValues \}/)
1775
- expect(server.contents).toMatch(/stripHiddenValues\(returnsSchema, formToValues\(/)
1776
- })
1777
-
1778
- it('leaves a schema without conditions exactly as it was', () => {
1779
- const p = createProject([customers])
1780
- const ssr = { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
1781
- const server = emitStudioProject(ssr).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
1782
- expect(server).not.toMatch(/stripHiddenValues/)
1783
- expect(server).toMatch(/const values = formToValues\(/)
1784
- })
1785
- })
1786
-
1787
- describe('form/dialog layout depth', () => {
1788
- const gridScreen = (extra: Partial<GridConfig>) => {
1789
- let p = createProject([customers])
1790
- const sid = p.screens[0]!.id
1791
- const gid = p.screens[0]!.blocks[0]!.id
1792
- p = updateBlock(p, sid, gid, { config: { editing: 'form', ...extra } as Partial<GridConfig> })
1793
- return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1794
- }
1795
-
1796
- const panelLine = (page: string) => page.split('\n').find((l) => l.includes('<SvGridEditPanel schema='))!
1797
-
1798
- it('emits nothing extra for a default form', () => {
1799
- const line = panelLine(gridScreen({}))
1800
- expect(line).toMatch(/<SvGridEditPanel schema=\{customersSchema\} row=\{editing\}/)
1801
- expect(line).not.toMatch(/columns=\{/)
1802
- expect(line).not.toMatch(/sections=\{/)
1803
- })
1804
-
1805
- it('passes columns / field selection+order / sections / title / size to the panel', () => {
1806
- const page = gridScreen({
1807
- formColumns: 3,
1808
- formFields: ['name', 'email'],
1809
- formSections: [{ title: 'Contact', fields: ['name', 'email'] }],
1810
- formTitle: 'Edit customer',
1811
- formSize: 'lg',
1812
- })
1813
- const line = panelLine(page)
1814
- expect(line).toMatch(/columns=\{3\}/)
1815
- expect(line).toMatch(/formFields=\{\["name","email"\]\}/)
1816
- expect(line).toMatch(/sections=\{\[\{"title":"Contact","fields":\["name","email"\]\}\]\}/)
1817
- expect(line).toMatch(/title=\{'Edit customer'\}/)
1818
- expect(line).toMatch(/formSize='lg'/)
1819
- expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
1820
- })
1821
-
1822
- it('omits default-valued knobs (columns 2, size md)', () => {
1823
- const line = panelLine(gridScreen({ formColumns: 2, formSize: 'md', formFields: ['name'] }))
1824
- expect(line).not.toMatch(/columns=\{/)
1825
- expect(line).not.toMatch(/formSize=/)
1826
- expect(line).toMatch(/formFields=\{\["name"\]\}/)
1827
- })
1828
- })
1829
-
1830
- describe('Component blocks', () => {
1831
- it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
1832
- let p = createProject([customers])
1833
- const sid = p.screens[0]!.id
1834
- p = addComponentBlock(p, sid, 'button', { variant: 'primary', block: true }, 0)
1835
- const files = emitStudioProject(p)
1836
-
1837
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1838
- const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
1839
- expect(importLine).toContain('SvButton') // merged into the screen's one @svgrid/grid import
1840
- // Full extracted surface: curated attrs first, then the rest of the component's
1841
- // real props (e.g. type={'button'}); content children last.
1842
- expect(page).toContain("<SvButton variant={'primary'} size={'md'} block")
1843
- expect(page).toContain(">{'Click me'}</SvButton>")
1844
-
1845
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1846
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1847
- }
1848
- })
1849
-
1850
- it('a freestanding screen with component blocks emits deduped imports + markup for each, and compiles', () => {
1851
- let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
1852
- const sid = p.screens.find((s) => s.title === 'Reports')!.id
1853
- p = addComponentBlock(p, sid, 'button', { variant: 'secondary' })
1854
- p = addComponentBlock(p, sid, 'badge', { variant: 'success' })
1855
- const files = emitStudioProject(p)
1856
-
1857
- const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1858
- expect(page).toContain("import { SvBadge, SvButton } from '@svgrid/grid'") // sorted, deduped
1859
- expect(page).toContain("<SvButton variant={'secondary'} size={'md'}")
1860
- expect(page).toContain(">{'Click me'}</SvButton>")
1861
- expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill")
1862
- expect(page).toContain(">{'Badge'}</SvBadge>")
1863
- expect(page).not.toContain('Add your own content here')
1864
-
1865
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1866
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1867
- }
1868
- })
1869
-
1870
- it('an empty freestanding screen (no blocks) still emits the placeholder comment', () => {
1871
- const p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
1872
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1873
- expect(page).toContain('Add your own content here')
1874
- })
1875
-
1876
- it('a component with a free-typed label containing quotes/braces cannot break out of the markup, and compiles', () => {
1877
- let p = createProject([customers])
1878
- const sid = p.screens[0]!.id
1879
- p = addComponentBlock(p, sid, 'button', { _content: `it's a "test" {value}` }, 0)
1880
- const files = emitStudioProject(p)
1881
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1882
- expect(() => compile(page, { filename: 'page.svelte', generate: 'client' })).not.toThrow()
1883
- })
1884
-
1885
- it('component props bound to data emit reactive expressions over allRows (aggregate / field / expr), get no code handle, and compile', () => {
1886
- let p = createProject([customers])
1887
- const sid = p.screens[0]!.id
1888
- const flat = () => flattenBlocks(p.screens.find((s) => s.id === sid)!.blocks).filter((b) => b.config.kind === 'component')
1889
-
1890
- p = addComponentBlock(p, sid, 'stat', { label: 'Total spend' })
1891
- const stat = flat()[0]!
1892
- p = setComponentBinding(p, sid, stat.id, 'value', { kind: 'aggregate', field: 'spend', reduce: 'sum' })
1893
-
1894
- p = addComponentBlock(p, sid, 'badge', { _content: 'tier' })
1895
- const badge = flat()[1]!
1896
- p = setComponentBinding(p, sid, badge.id, '_content', { kind: 'field', field: 'tier' })
1897
-
1898
- p = addComponentBlock(p, sid, 'progress', { value: 0, max: 100 })
1899
- const prog = flat()[2]!
1900
- p = setComponentBinding(p, sid, prog.id, 'value', { kind: 'expr', code: 'rows.filter((r) => r.spend > 100).length' })
1901
-
1902
- const files = emitStudioProject(p)
1903
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1904
-
1905
- // aggregate -> reduceValue over allRows; imported once.
1906
- expect(page).toContain("value={(reduceValue(allRows, { measure: 'spend', reduce: 'sum' })).toLocaleString()}")
1907
- expect(page).toMatch(/import \{[^}]*reduceValue[^}]*\} from '@svgrid\/enterprise'/)
1908
- // field -> first row's value.
1909
- expect(page).toContain("{String(allRows[0]?.['tier'] ?? '')}")
1910
- // expr -> the user code wrapped with rows = allRows.
1911
- expect(page).toContain('value={((rows) => (rows.filter((r) => r.spend > 100).length))(allRows)}')
1912
- // the screen loads every row for the bindings.
1913
- expect(page).toContain('async function loadAll()')
1914
- // bound components are static reactive markup, not code handles.
1915
- expect(page).not.toContain('= handle(')
1916
-
1917
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1918
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1919
- }
1920
- })
1921
-
1922
- it('every registry component codegens to a page that compiles, from the full UI kit', () => {
1923
- let p = addFreestandingScreen(createProject([customers]), { title: 'Sink', route: 'sink' })
1924
- const sid = p.screens.find((s) => s.title === 'Sink')!.id
1925
- for (const spec of UI_COMPONENT_REGISTRY) p = addComponentBlock(p, sid, spec.key)
1926
- const files = emitStudioProject(p)
1927
- const page = files.find((f) => f.path === 'src/routes/sink/+page.svelte')!.contents
1928
- // The kit spans well beyond the original eight (inputs, nav, display, ...).
1929
- expect(UI_COMPONENT_REGISTRY.length).toBeGreaterThanOrEqual(24)
1930
- // Every importName is present in the single @svgrid/grid import.
1931
- for (const spec of UI_COMPONENT_REGISTRY) expect(page).toContain(spec.importName)
1932
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1933
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1934
- }
1935
- })
1936
-
1937
- it('array/object "fixed" props (Timeline items, Sparkline data) emit verbatim, not stringified', () => {
1938
- let p = addFreestandingScreen(createProject([customers]), { title: 'Viz', route: 'viz' })
1939
- const sid = p.screens.find((s) => s.title === 'Viz')!.id
1940
- p = addComponentBlock(p, sid, 'timeline')
1941
- p = addComponentBlock(p, sid, 'sparkline', { type: 'line' })
1942
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/viz/+page.svelte')!.contents
1943
- expect(page).toContain("items={[{ title: 'Order placed'") // real array expression
1944
- expect(page).toContain('data={[4, 8, 5, 9') // real number array
1945
- expect(page).not.toContain("items={'[") // never quoted as a string
1946
- })
1947
-
1948
- it('an unrecognized component key emits a harmless placeholder comment instead of throwing', () => {
1949
- let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
1950
- const sid = p.screens.find((s) => s.title === 'Reports')!.id
1951
- p = addComponentBlock(p, sid, 'not-a-real-component', {})
1952
- const files = emitStudioProject(p)
1953
- const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1954
- expect(page).toContain('unknown component "not-a-real-component"')
1955
- expect(() => compile(page, { filename: 'page.svelte', generate: 'client' })).not.toThrow()
1956
- })
1957
- })
1958
-
1959
- describe('data-source codegen', () => {
1960
- const byPathOf = (files: ReturnType<typeof emitStudioProject>, path: string) => files.find((f) => f.path === path)
1961
-
1962
- it('emits createRestDataSource for a REST-bound entity with response mapping', () => {
1963
- let p = createProject([customers])
1964
- p = setEntityDataSource(p, 'customers', {
1965
- kind: 'rest', baseUrl: 'https://api.example.com/v1', path: 'customers', method: 'GET',
1966
- params: [
1967
- { name: 'X-Key', location: 'header', type: 'string', value: 'abc' },
1968
- { name: 'region', location: 'query', type: 'string', value: 'eu' },
1969
- ],
1970
- rowsPath: 'data.items', totalPath: 'data.total',
1971
- })
1972
- const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1973
- expect(dataTs).toContain('createRestDataSource')
1974
- expect(dataTs).toContain("url: 'https://api.example.com/v1/customers'")
1975
- expect(dataTs).toContain("headers: { 'X-Key': 'abc' }")
1976
- expect(dataTs).toContain("query: { 'region': 'eu' }") // static query params baked into every read
1977
- expect(dataTs).toContain('body?.data?.items')
1978
- expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
1979
- })
1980
-
1981
- it('REST adapter (dummyjson): emits real server paging via the adapter + imports it', () => {
1982
- let p = createProject([customers])
1983
- p = setEntityDataSource(p, 'customers', {
1984
- kind: 'rest', baseUrl: 'https://dummyjson.com', path: 'users', method: 'GET', params: [],
1985
- adapter: { kind: 'dummyjson', rowsKey: 'users' },
1986
- })
1987
- const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1988
- expect(dataTs).toContain('dummyJsonAdapter') // imported from @svgrid/enterprise
1989
- expect(dataTs).toMatch(/\.\.\.dummyJsonAdapter<\w+>\('users'\)/)
1990
- })
1991
-
1992
- it('REST offsetLimit adapter emits only the configured params', () => {
1993
- let p = createProject([customers])
1994
- p = setEntityDataSource(p, 'customers', {
1995
- kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
1996
- adapter: { kind: 'offsetLimit', offsetParam: '_start', limitParam: '_limit', sortByParam: 'sort' },
1997
- })
1998
- const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1999
- expect(dataTs).toContain("offsetParam: '_start'")
2000
- expect(dataTs).toContain("limitParam: '_limit'")
2001
- expect(dataTs).toContain("sortByParam: 'sort'")
2002
- expect(dataTs).not.toContain('orderParam') // unset fields are omitted
2003
- })
2004
-
2005
- it('a REST adapter supersedes the manual rowsPath/totalPath parse', () => {
2006
- let p = createProject([customers])
2007
- p = setEntityDataSource(p, 'customers', {
2008
- kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
2009
- adapter: { kind: 'jsonServer' }, rowsPath: 'data', totalPath: 'total',
2010
- })
2011
- const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
2012
- expect(dataTs).toContain('jsonServerAdapter')
2013
- expect(dataTs).not.toContain('parse: (body: any)') // adapter provides parse instead
2014
- })
2015
-
2016
- it('substitutes static path params into the URL', () => {
2017
- let p = createProject([customers])
2018
- p = setEntityDataSource(p, 'customers', {
2019
- kind: 'rest', baseUrl: 'https://api', path: 'albums/{id}/tracks', method: 'GET',
2020
- params: [{ name: 'id', location: 'path', type: 'string', value: '5' }],
2021
- })
2022
- const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
2023
- expect(dataTs).toContain("url: 'https://api/albums/5/tracks'")
2024
- })
2025
-
2026
- it('SQL-bound entity emits a connected +server.ts route + a Kit transport client', () => {
2027
- let p = createProject([customers, orders])
2028
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
2029
- const files = emitStudioProject(p)
2030
- const dataTs = byPathOf(files, 'src/lib/data.ts')!.contents
2031
- // Client talks to the API route, not a raw SQL source in the browser.
2032
- expect(dataTs).toContain("createKitDataSource<Orders>({ endpoint: '/api/orders' })")
2033
- expect(dataTs).not.toContain('createSqlDataSource')
2034
- const route = byPathOf(files, 'src/routes/api/orders/+server.ts')
2035
- expect(route).toBeTruthy()
2036
- expect(route!.contents).toContain("import pg from 'pg'")
2037
- expect(route!.contents).toContain("import { env } from '$env/dynamic/private'")
2038
- expect(route!.contents).toContain('createSqlDataSource<Orders>')
2039
- expect(route!.contents).toContain("dialect: { placeholders: '$', ilike: true }")
2040
- expect(route!.contents).toContain('createKitHandlers({')
2041
- expect(route!.contents).toContain('schema: ordersSchema')
2042
- expect(route!.contents).toContain('validate: true') // server-enforced validation on every connected route
2043
- // No connections.ts when nothing needs a browser-side client.
2044
- expect(byPathOf(files, 'src/lib/connections.ts')).toBeUndefined()
2045
- })
2046
-
2047
- it('picks the right driver per dialect', () => {
2048
- for (const [dialect, needle] of [['mysql', "import mysql from 'mysql2/promise'"], ['sqlite', "import Database from 'better-sqlite3'"], ['mssql', "import mssql from 'mssql'"], ['turso', "import { createClient } from '@libsql/client'"]] as const) {
2049
- let p = createProject([customers])
2050
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
2051
- const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!
2052
- expect(route.contents).toContain(needle)
2053
- }
2054
- })
2055
-
2056
- it('Provisioning: a Turso (libSQL) entity wires the client + auth token + a .env.example', () => {
2057
- let p = createProject([customers])
2058
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'turso' })
2059
- const bundle = emitStudioAppBundle(p)
2060
- const route = bundle.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
2061
- expect(route).toContain("import { createClient } from '@libsql/client'")
2062
- expect(route).toContain('env.DATABASE_AUTH_TOKEN')
2063
- expect(route).toContain("dialect: { placeholders: '?' }")
2064
- const pkg = bundle.find((f) => f.path === 'package.json')!.contents
2065
- expect(pkg).toContain('@libsql/client')
2066
- const envEx = bundle.find((f) => f.path === '.env.example')!.contents
2067
- expect(envEx).toContain('DATABASE_URL=')
2068
- expect(envEx).toContain('DATABASE_AUTH_TOKEN=')
2069
- })
2070
-
2071
- it('.env.example lists DATABASE_URL for SQL apps, and is omitted for zero-setup apps', () => {
2072
- let sql = createProject([customers])
2073
- sql = setEntityDataSource(sql, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
2074
- const envEx = emitStudioAppBundle(sql).find((f) => f.path === '.env.example')!.contents
2075
- expect(envEx).toContain('DATABASE_URL=')
2076
- expect(envEx).not.toContain('DATABASE_AUTH_TOKEN')
2077
- // In-memory / PGlite apps need no env, so no .env.example.
2078
- expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
2079
- })
2080
-
2081
- it('emits a Supabase client that reads PUBLIC_SUPABASE_* env vars (never inlines the key)', () => {
2082
- let p = createProject([customers])
2083
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
2084
- const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
2085
- expect(conn).toContain("import { createClient } from '@supabase/supabase-js'")
2086
- expect(conn).toContain("import { env } from '$env/dynamic/public'")
2087
- expect(conn).toContain('createClient(')
2088
- expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
2089
- expect(conn).toContain('env.PUBLIC_SUPABASE_ANON_KEY')
2090
- // The anon key is NEVER baked into source (it goes to .env); the designer values
2091
- // appear only as a paste-into-.env comment.
2092
- expect(conn).not.toContain("createClient('https://x.supabase.co'")
2093
- // .env.example lists the public Supabase keys.
2094
- const envEx = emitStudioAppBundle(p).find((f) => f.path === '.env.example')!.contents
2095
- expect(envEx).toContain('PUBLIC_SUPABASE_URL=')
2096
- expect(envEx).toContain('PUBLIC_SUPABASE_ANON_KEY=')
2097
- })
2098
-
2099
- it('still emits an env-var Supabase client without designer url/key', () => {
2100
- let p = createProject([customers])
2101
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
2102
- const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
2103
- expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
2104
- expect(conn).not.toContain('null as unknown as SupabaseClientLike') // no stub - reads env
2105
- })
2106
-
2107
- it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
2108
- let p = createProject([customers, orders])
2109
- p = setEntityDataSource(p, 'customers', { kind: 'rest', baseUrl: 'https://api', path: 'customers', method: 'GET', params: [] })
2110
- p = setEntityDataSource(p, 'orders', { kind: 'supabase', table: 'orders' })
2111
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2112
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2113
- }
2114
- })
2115
-
2116
- it('Supabase realtime: subscribes the grid page to live changes and refreshes on change', () => {
2117
- let p = createProject([customers])
2118
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', realtime: true })
2119
- const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
2120
- expect(page).toContain('createSupabaseRealtime')
2121
- expect(page).toContain("import { supabaseClient } from '$lib/connections'")
2122
- expect(page).toContain("table: 'customers'")
2123
- expect(page).toContain('onChange: () => controller.refresh()')
2124
- // Cleanup unsubscribes the channel alongside disposing the controller.
2125
- expect(page).toContain('__rt.unsubscribe()')
2126
- expect(page).toContain('controller.dispose()')
2127
- // Compiles.
2128
- expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
2129
- })
2130
-
2131
- it('Supabase without realtime does NOT emit a subscription', () => {
2132
- let p = createProject([customers])
2133
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
2134
- const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
2135
- expect(page).not.toContain('createSupabaseRealtime')
2136
- })
2137
-
2138
- it('project-level Supabase connection feeds the .env paste hint (entity carries only the table)', () => {
2139
- let p = createProject([customers])
2140
- p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
2141
- p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anonKEY' } }
2142
- const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
2143
- expect(conn).toContain('PUBLIC_SUPABASE_URL=https://proj.supabase.co')
2144
- expect(conn).toContain('PUBLIC_SUPABASE_ANON_KEY=anonKEY')
2145
- // The client still reads env (project-level values only seed the paste comment).
2146
- expect(conn).not.toContain("createClient('https://proj.supabase.co'")
2147
- })
2148
-
2149
- it('SQL schema selector qualifies the table in the generated route', () => {
2150
- let p = createProject([customers])
2151
- p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres', schema: 'analytics' })
2152
- const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!.contents
2153
- expect(route).toContain('dbSchema: "analytics"')
2154
- // No schema set -> no dbSchema option (unqualified table).
2155
- let q = createProject([customers])
2156
- q = setEntityDataSource(q, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
2157
- expect(byPathOf(emitStudioProject(q), 'src/routes/api/customers/+server.ts')!.contents).not.toContain('dbSchema:')
2158
- })
2159
- })
2160
-
2161
- describe('grid editing (a Grid property) -> codegen', () => {
2162
- const pageFor = (p: Parameters<typeof emitStudioProject>[0]) =>
2163
- emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2164
- const withGrid = (patch: Partial<GridConfig>) => {
2165
- let p = createProject([customers])
2166
- const sid = p.screens[0]!.id
2167
- const gid = p.screens[0]!.blocks[0]!.id
2168
- p = updateBlock(p, sid, gid, { config: patch as Partial<import('./project').BlockConfig> })
2169
- return p
2170
- }
2171
-
2172
- it('form editing (default) emits the edit panel + New button + non-editable cells', () => {
2173
- const page = pageFor(createProject([customers]))
2174
- expect(page).toContain('<SvGridEditPanel')
2175
- expect(page).toContain('+ New Customer')
2176
- expect(page).toContain('onRowDoubleClick')
2177
- expect(page).toContain('editable: false')
2178
- })
2179
-
2180
- it('inline editing emits onCellValueChange -> updateRow, editable cells, no edit panel', () => {
2181
- const page = pageFor(withGrid({ editing: 'inline' }))
2182
- expect(page).toContain('onCellValueChange')
2183
- expect(page).toContain('controller.updateRow(')
2184
- expect(page).not.toContain('<SvGridEditPanel')
2185
- expect(page).not.toContain('editable: false')
2186
- })
2187
-
2188
- it('read-only editing emits no editors', () => {
2189
- const page = pageFor(withGrid({ editing: 'none' }))
2190
- expect(page).not.toContain('onCellValueChange')
2191
- expect(page).not.toContain('<SvGridEditPanel')
2192
- expect(page).not.toContain('onRowDoubleClick')
2193
- expect(page).toContain('editable: false')
2194
- })
2195
-
2196
- it('sortable / filterable toggles gate the grid props', () => {
2197
- const page = pageFor(withGrid({ sortable: false, filterable: true }))
2198
- expect(page).not.toContain('onSortingChange')
2199
- expect(page).toContain('onFiltersChange')
2200
- expect(page).toContain('controller.setFilter')
2201
- })
2202
-
2203
- it('filterUi picks the filter surfaces (row / menu / global); default is the global search', () => {
2204
- // Default (no filterUi) -> global search only.
2205
- const dflt = pageFor(withGrid({ filterable: true }))
2206
- expect(dflt).toContain('showGlobalFilter')
2207
- expect(dflt).not.toContain('showFilterRow')
2208
- expect(dflt).not.toContain('showFilterMenu')
2209
- // Explicit row + menu (no global).
2210
- const rm = pageFor(withGrid({ filterable: true, filterUi: { row: true, menu: true } }))
2211
- expect(rm).toContain('showFilterRow')
2212
- expect(rm).toContain('showFilterMenu')
2213
- expect(rm).not.toContain('showGlobalFilter')
2214
- // Column filters still flow to the server controller.
2215
- expect(rm).toContain('externalFilter')
2216
- expect(rm).toContain('onFiltersChange')
2217
- })
2218
-
2219
- it('form presentation is honored', () => {
2220
- expect(pageFor(withGrid({ formPresentation: 'drawer' }))).toContain('presentation="drawer"')
2221
- })
2222
-
2223
- it('page size flows into the controller', () => {
2224
- expect(pageFor(withGrid({ pageSize: 25 }))).toContain('pageSize: 25')
2225
- })
2226
-
2227
- it('striped / cell-selection / totals / density map to grid props', () => {
2228
- const page = pageFor(withGrid({ striped: true, cellSelection: true, rowSummaries: true, density: 'compact' }))
2229
- expect(page).toContain('zebraRows')
2230
- expect(page).toContain('enableCellSelection')
2231
- expect(page).toContain('enableRowSummaries={true}')
2232
- expect(page).toContain('rowHeight={28}')
2233
- })
2234
-
2235
- it('normal density emits the standard 30px row height (consistent with master-detail) + totals off', () => {
2236
- const page = pageFor(createProject([customers]))
2237
- expect(page).toContain('enableRowSummaries={false}')
2238
- expect(page).toContain('rowHeight={30}')
2239
- })
2240
-
2241
- it('per-column header / width / align overrides flow into the columns', () => {
2242
- let p = createProject([customers])
2243
- const sid = p.screens[0]!.id
2244
- const gid = p.screens[0]!.blocks[0]!.id
2245
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2246
- const columns = cfg.columns.map((c) => (c.field === 'name' ? { ...c, header: 'Full name', width: 220, align: 'center' as const } : c))
2247
- p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
2248
- const page = pageFor(p)
2249
- expect(page).toContain("'name': { header: \"Full name\", width: 220, align: 'center' }")
2250
- expect(page).toContain('...(ov[String(c.field)] ?? {})')
2251
- })
2252
-
2253
- it('pager position + page-size options map to grid props', () => {
2254
- const page = pageFor(withGrid({ paginationPosition: 'both', pageSizeOptions: [20, 40, 80] }))
2255
- expect(page).toContain('showPagination')
2256
- expect(page).toContain('paginationPosition="both"')
2257
- expect(page).toContain('pageSizeOptions={[20, 40, 80]}')
2258
- })
2259
-
2260
- it('default paging (bottom, [10,25,50,100]) stays implicit', () => {
2261
- const page = pageFor(createProject([customers]))
2262
- expect(page).toContain('showPagination')
2263
- expect(page).not.toContain('paginationPosition=')
2264
- expect(page).not.toContain('pageSizeOptions=')
2265
- })
2266
-
2267
- it('an unpaginated grid loads all rows and shows no pager', () => {
2268
- const page = pageFor(withGrid({ paginated: false }))
2269
- expect(page).not.toContain('showPagination')
2270
- expect(page).toContain('pageSize: 1000') // controller loads everything at once
2271
- })
2272
-
2273
- it('column pinning emits initialColumnPinning + disables column virtualization', () => {
2274
- let p = createProject([customers])
2275
- const sid = p.screens[0]!.id
2276
- const gid = p.screens[0]!.blocks[0]!.id
2277
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2278
- const columns = cfg.columns.map((c) => (c.field === 'name' ? { ...c, pin: 'left' as const } : c))
2279
- p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
2280
- const page = pageFor(p)
2281
- expect(page).toContain("initialColumnPinning={{ left: ['name'] }}")
2282
- expect(page).toContain('columnVirtualization={false}')
2283
- })
2284
-
2285
- it('row grouping switches the grid to full-client mode + seeds setGroupBy + rolls up column aggregates', () => {
2286
- let p = createProject([customers])
2287
- const sid = p.screens[0]!.id
2288
- const gid = p.screens[0]!.blocks[0]!.id
2289
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2290
- const columns = cfg.columns.map((c) => (c.field === 'mrr' ? { ...c, aggregate: 'sum' as const } : c))
2291
- p = updateBlock(p, sid, gid, { config: { grouping: ['tier'], columns } as Partial<import('./project').BlockConfig> })
2292
- const page = pageFor(p)
2293
- // Full-client data + grouping controls + seeded grouping.
2294
- expect(page).toContain('data={allRows}')
2295
- expect(page).toContain('loading={!allRowsReady}')
2296
- expect(page).toContain('groupable')
2297
- expect(page).toContain("a.setGroupBy(['tier'])")
2298
- // Per-column aggregate flows into the column override.
2299
- expect(page).toContain("'mrr': { aggregate: 'sum' }")
2300
- // Client-side sort/paginate, NOT the server controller wiring.
2301
- expect(page).not.toContain('externalSort')
2302
- expect(page).not.toContain('externalPagination')
2303
- expect(page).not.toContain('onSortingChange')
2304
- // Loads the whole dataset.
2305
- expect(page).toContain('async function loadAll()')
2306
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2307
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2308
- }
2309
- })
2310
-
2311
- it('per-column value formats compile to the grid format (CellFormatConfig)', () => {
2312
- let p = createProject([customers])
2313
- const sid = p.screens[0]!.id
2314
- const gid = p.screens[0]!.blocks[0]!.id
2315
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2316
- const columns = cfg.columns.map((c) =>
2317
- c.field === 'mrr' ? { ...c, format: { type: 'currency', currency: 'EUR' } as const } : c,
2318
- )
2319
- p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
2320
- const page = pageFor(p)
2321
- expect(page).toContain("'mrr': { format: { type: 'currency', currency: 'EUR' } }")
2322
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2323
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2324
- }
2325
- })
2326
-
2327
- it('percent + number formats emit decimals via Intl options', () => {
2328
- let p = createProject([customers])
2329
- const sid = p.screens[0]!.id
2330
- const gid = p.screens[0]!.blocks[0]!.id
2331
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2332
- const columns = cfg.columns.map((c) =>
2333
- c.field === 'mrr' ? { ...c, format: { type: 'percent', decimals: 1, valueIsPercentPoints: true } as const } : c,
2334
- )
2335
- p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
2336
- expect(pageFor(p)).toContain("format: { type: 'percent', valueIsPercentPoints: true, options: { minimumFractionDigits: 1, maximumFractionDigits: 1 } }")
2337
- })
2338
-
2339
- it('rich cell renderers emit per-column cell snippets + imports (badge / progress / link) and compile', () => {
2340
- let p = createProject([customers])
2341
- const sid = p.screens[0]!.id
2342
- const gid = p.screens[0]!.blocks[0]!.id
2343
- const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
2344
- const columns = cfg.columns.map((c) =>
2345
- c.field === 'tier' ? { ...c, cellType: { kind: 'badge' } as const }
2346
- : c.field === 'mrr' ? { ...c, cellType: { kind: 'progress', max: 500 } as const }
2347
- : c.field === 'name' ? { ...c, cellType: { kind: 'link', as: 'email' } as const }
2348
- : c,
2349
- )
2350
- p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
2351
- const page = pageFor(p)
2352
- // Imports pulled in for the renderers.
2353
- expect(page).toMatch(/import \{[^}]*renderSnippet[^}]*\} from '@svgrid\/grid'/)
2354
- expect(page).toMatch(/import \{[^}]*SvBadge[^}]*\} from '@svgrid\/grid'/)
2355
- expect(page).toMatch(/import \{[^}]*SvProgress[^}]*\} from '@svgrid\/grid'/)
2356
- // Cell refs + snippets.
2357
- expect(page).toContain('cell: (ctx: CellContext<Customers>) => renderSnippet(cellRender_')
2358
- expect(page).toContain('<SvBadge variant={stBadgeVariant(value)}')
2359
- expect(page).toContain('max={500}')
2360
- expect(page).toContain("href={'mailto:' + String(value ?? '')}")
2361
- // The shared badge-intent helper is emitted once.
2362
- expect(page.match(/function stBadgeVariant\(/g)?.length).toBe(1)
2363
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2364
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2365
- }
2366
- })
2367
-
2368
- it('an export toolbar captures the grid api + wires CSV / JSON / copy buttons, and compiles', () => {
2369
- let p = createProject([customers])
2370
- const sid = p.screens[0]!.id
2371
- const gid = p.screens[0]!.blocks[0]!.id
2372
- p = updateBlock(p, sid, gid, { config: { export: { csv: true, json: true, copy: true } } as Partial<import('./project').BlockConfig> })
2373
- const page = pageFor(p)
2374
- expect(page).toMatch(/import \{[^}]*type SvGridApi[^}]*\} from '@svgrid\/grid'/)
2375
- expect(page).toContain('let gridApi_' + gid.replace(/-/g, '_') + ' = $state<SvGridApi<any, any> | null>(null)')
2376
- expect(page).toContain('<div class="st-grid-toolbar">')
2377
- expect(page).toContain('.exportCsv({ filename: \'customers\' })')
2378
- expect(page).toContain('.exportJson({ filename: \'customers\' })')
2379
- expect(page).toContain('.copyToClipboard()')
2380
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2381
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2382
- }
2383
- })
2384
-
2385
- it('no export config emits no toolbar', () => {
2386
- expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
2387
- })
2388
-
2389
- it('xlsx / pdf / print buttons go through @svgrid/enterprise and compile', () => {
2390
- let p = createProject([customers])
2391
- const sid = p.screens[0]!.id
2392
- const gid = p.screens[0]!.blocks[0]!.id
2393
- p = updateBlock(p, sid, gid, { config: { export: { xlsx: true, pdf: true, print: true } } as Partial<import('./project').BlockConfig> })
2394
- const page = pageFor(p)
2395
- // Imported from the enterprise pack, not the free grid api.
2396
- expect(page).toMatch(/import \{[^}]*exportGrid[^}]*\} from '@svgrid\/enterprise'/)
2397
- expect(page).toMatch(/import \{[^}]*printGrid[^}]*\} from '@svgrid\/enterprise'/)
2398
- expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'xlsx', 'customers')")
2399
- expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'pdf', 'customers')")
2400
- expect(page).toContain('stPrint(gridApi_' + gid.replace(/-/g, '_') + ", 'customers')")
2401
- // Helpers emitted exactly once each, beside the buttons that call them.
2402
- expect(page.match(/async function stExport\(/g)?.length).toBe(1)
2403
- expect(page.match(/async function stPrint\(/g)?.length).toBe(1)
2404
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2405
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2406
- }
2407
- })
2408
-
2409
- it('declares the optional peer deps the enterprise exporters need', () => {
2410
- const base = createProject([customers])
2411
- const sid = base.screens[0]!.id
2412
- const gid = base.screens[0]!.blocks[0]!.id
2413
- const pkgOf = (proj: typeof base) => {
2414
- const f = emitStudioAppBundle(proj).find((f) => f.path === 'package.json')!
2415
- return JSON.parse(f.contents).dependencies as Record<string, string>
2416
- }
2417
- // jszip (xlsx) and pdfmake (pdf) are imported lazily INSIDE @svgrid/enterprise,
2418
- // so they never appear in the generated source - they must come from the config.
2419
- const xlsxDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { xlsx: true } } as Partial<import('./project').BlockConfig> }))
2420
- expect(xlsxDeps).toHaveProperty('jszip')
2421
- expect(xlsxDeps).not.toHaveProperty('pdfmake')
2422
-
2423
- const pdfDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { pdf: true } } as Partial<import('./project').BlockConfig> }))
2424
- expect(pdfDeps).toHaveProperty('pdfmake')
2425
- expect(pdfDeps).not.toHaveProperty('jszip')
2426
-
2427
- // A csv-only app drags in neither.
2428
- const csvDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { csv: true } } as Partial<import('./project').BlockConfig> }))
2429
- expect(csvDeps).not.toHaveProperty('jszip')
2430
- expect(csvDeps).not.toHaveProperty('pdfmake')
2431
- })
2432
-
2433
- it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
2434
- let p = createProject([customers])
2435
- const sid = p.screens[0]!.id
2436
- const gid = p.screens[0]!.blocks[0]!.id
2437
- p = updateBlock(p, sid, gid, { config: { treeData: { parentField: 'tier', labelField: 'name' } } as Partial<import('./project').BlockConfig> })
2438
- const page = pageFor(p)
2439
- // Full-client tree state + derivation.
2440
- expect(page).toContain('let treeExpanded_' + gid.replace(/-/g, '_'))
2441
- expect(page).toContain('function treeBuild_' + gid.replace(/-/g, '_'))
2442
- expect(page).toContain('data={tree_' + gid.replace(/-/g, '_') + '.visible}')
2443
- expect(page).toContain('loading={!allRowsReady}')
2444
- // Tree cell on the label column; other columns untouched.
2445
- expect(page).toContain('renderSnippet(treeCell_' + gid.replace(/-/g, '_'))
2446
- expect(page).toContain('{#snippet treeCell_' + gid.replace(/-/g, '_'))
2447
- expect(page).toContain('async function loadAll()')
2448
- // No flat sort / paginate / grouping that would break the hierarchy.
2449
- expect(page).not.toContain('externalSort')
2450
- expect(page).not.toContain('showPagination')
2451
- expect(page).not.toContain('groupable')
2452
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2453
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2454
- }
2455
- })
2456
-
2457
- it('scheduler view renders the grid as a calendar: enableSchedulerView + scheduler prop + write-back, and compiles', () => {
2458
- let p = createProject([customers])
2459
- const sid = p.screens[0]!.id
2460
- const gid = p.screens[0]!.blocks[0]!.id
2461
- p = updateBlock(p, sid, gid, { config: { scheduler: { startField: 'mrr', titleField: 'name', colorField: 'tier', initialView: 'week', editable: true, drawer: true } } as Partial<import('./project').BlockConfig> })
2462
- const page = pageFor(p)
2463
- // Renderer registered once + imported.
2464
- expect(page).toMatch(/import \{[^}]*enableSchedulerView[^}]*\} from '@svgrid\/enterprise'/)
2465
- expect(page).toContain('enableSchedulerView()')
2466
- // Full-client scheduler grid with the mapped config.
2467
- expect(page).toContain('data={allRows}')
2468
- expect(page).toContain("scheduler={{ startField: 'mrr'")
2469
- expect(page).toContain("titleField: 'name'")
2470
- expect(page).toContain("initialView: 'week'")
2471
- expect(page).toContain('editable: true')
2472
- // Optimistic write-back through the controller.
2473
- expect(page).toContain('onEventMove:')
2474
- expect(page).toContain('onEventCommit:')
2475
- expect(page).toContain('controller.updateRow(')
2476
- expect(page).toContain('async function loadAll()')
2477
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2478
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2479
- }
2480
- })
2481
-
2482
- it('an ungrouped grid stays server-driven (no groupable, keeps the controller view)', () => {
2483
- const page = pageFor(createProject([customers]))
2484
- expect(page).not.toContain('groupable')
2485
- expect(page).not.toContain('setGroupBy')
2486
- expect(page).toContain('data={view.rows}')
2487
- expect(page).toContain('externalSort')
2488
- })
2489
- })
2490
-
2491
- describe('dock layout (screen.layout = dock)', () => {
2492
- it('a dock screen renders SvDockManager with a pane per block + persistence + mobile stack, and compiles', () => {
2493
- let p = createProject([customers])
2494
- const sid = p.screens[0]!.id
2495
- p = addBlock(p, sid, 'filter')
2496
- p = addBlock(p, sid, 'kpi')
2497
- p = setScreenLayout(p, sid, 'dock')
2498
- const files = emitStudioProject(p)
2499
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2500
- // Imports + workspace state + persistence.
2501
- expect(page).toMatch(/import \{[^}]*SvDockManager[^}]*\} from '@svgrid\/grid'/)
2502
- expect(page).toContain('let dockWorkspace = $state<DockManagerState>(')
2503
- expect(page).toContain("localStorage.getItem('dock:customers')")
2504
- expect(page).toContain('let dockNarrow = $state(false)')
2505
- // The manager + a pane per block, wired to persist on change.
2506
- expect(page).toContain('<SvDockManager bind:workspace={dockWorkspace} onChange={(w) => saveDock(w)}>')
2507
- expect(page).toContain('{#snippet pane(p)}')
2508
- for (const b of p.screens[0]!.blocks) expect(page).toContain(`{#if p.id === '${b.id}'}`)
2509
- // Mobile fallback stacks the plain grid body.
2510
- expect(page).toContain('{#if dockNarrow}')
2511
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2512
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2513
- }
2514
- })
2515
-
2516
- it('a canvas-layout screen places blocks on a 12-col grid by cell coords and compiles', () => {
2517
- let p = createProject([customers])
2518
- const sid = p.screens[0]!.id
2519
- p = addBlock(p, sid, 'kpi')
2520
- p = setScreenLayout(p, sid, 'canvas')
2521
- const files = emitStudioProject(p)
2522
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2523
- expect(page).toContain('<div class="st-canvas">')
2524
- expect(page).toContain('<div class="st-canvas__cell" style="grid-column:')
2525
- expect(page).toMatch(/grid-column: \d+ \/ span \d+; grid-row: \d+ \/ span \d+;/)
2526
- expect(page).not.toContain('SvDockManager')
2527
- const css = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
2528
- expect(css).toContain('.st-canvas { display: grid; grid-template-columns: repeat(12, 1fr);')
2529
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2530
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2531
- }
2532
- })
2533
-
2534
- it('layout settings flow into codegen (dock props, persist off, canvas cols, grid gap)', () => {
2535
- // Dock with pop-out + bottom tabs + no persistence.
2536
- let d = createProject([customers])
2537
- const dsid = d.screens[0]!.id
2538
- d = addBlock(d, dsid, 'kpi')
2539
- d = setScreenLayout(d, dsid, 'dock')
2540
- d = setLayoutOpts(d, dsid, 'dock', { allowPopout: true, headerPosition: 'bottom', persist: false })
2541
- const dpage = emitStudioProject(d).find((f) => f.path.endsWith('/+page.svelte'))!.contents
2542
- expect(dpage).toContain('allowPopout')
2543
- expect(dpage).toContain('headerPosition="bottom"')
2544
- expect(dpage).not.toContain('onChange={(w) => saveDock(w)}') // persistence off
2545
- expect(dpage).not.toContain('function saveDock')
2546
-
2547
- // Canvas with 24 columns + custom row height.
2548
- let c = createProject([customers])
2549
- const csid = c.screens[0]!.id
2550
- c = setScreenLayout(c, csid, 'canvas')
2551
- c = setLayoutOpts(c, csid, 'canvas', { cols: 24, rowHeight: 32 })
2552
- const cpage = emitStudioProject(c).find((f) => f.path.endsWith('/+page.svelte'))!.contents
2553
- expect(cpage).toContain('grid-template-columns: repeat(24, 1fr); grid-auto-rows: 32px;')
2554
-
2555
- // Grid gap setting scoped into the page style.
2556
- let g = createProject([customers])
2557
- const gsid = g.screens[0]!.id
2558
- g = setLayoutOpts(g, gsid, 'grid', { colGap: 24, rowGap: 8 })
2559
- const gpage = emitStudioProject(g).find((f) => f.path.endsWith('/+page.svelte'))!.contents
2560
- expect(gpage).toContain('.st-screen { grid-template-columns: repeat(12, 1fr); gap: 8px 24px;')
2561
-
2562
- for (const files of [emitStudioProject(d), emitStudioProject(c), emitStudioProject(g)]) {
2563
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2564
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2565
- }
2566
- }
2567
- })
2568
-
2569
- it('state variables + logic-core steps emit reactive state, ctx.state, and compiled code', () => {
2570
- let p = createProject([customers])
2571
- const sid = p.screens[0]!.id
2572
- p = addStateVar(p, sid, { name: 'query', type: 'string', initial: 'hi' })
2573
- p = addStateVar(p, sid, { name: 'count', type: 'number', initial: '0' })
2574
- // A click handler that sets a var, then branches on it.
2575
- p = addComponentBlock(p, sid, 'button', {})
2576
- const btn = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'component')!
2577
- p = setHandlerSteps(p, sid, `click:${btn.id}`, [
2578
- { type: 'setVar', name: 'count', value: { kind: 'literal', value: '3' } },
2579
- { type: 'branch', condition: { left: { kind: 'state', name: 'count' }, op: 'gt', right: { kind: 'literal', value: '2' } }, then: [{ type: 'alert', message: 'big' }] },
2580
- ])
2581
- const files = emitStudioProject(p)
2582
- const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
2583
- expect(page).toContain("let query = $state<string>('hi')")
2584
- expect(page).toContain('let count = $state<number>(0)')
2585
- expect(page).toContain('state: { get query() { return query }, set query(x) { query = x }, get count() { return count }, set count(x) { count = x } }')
2586
- // The compiled step code lives in the handlers.ts companion (onLoad wires the onclick).
2587
- const handlers = files.find((f) => f.path.endsWith('/handlers.ts'))!.contents
2588
- expect(handlers).toContain('ctx.state.count = 3')
2589
- expect(handlers).toContain('if (Number(ctx.state.count) > Number(2)) {')
2590
- const pctx = files.find((f) => f.path.endsWith('/page-context.ts'))!.contents
2591
- expect(pctx).toContain('state: { query: string; count: number }')
2592
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2593
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2594
- }
2595
- })
2596
-
2597
- it('grid row-select event slot wires onRowClick to compiled steps (cross-block state)', () => {
2598
- let p = createProject([customers])
2599
- const sid = p.screens[0]!.id
2600
- p = addStateVar(p, sid, { name: 'selName', type: 'string' })
2601
- const gridBlock = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'grid')!
2602
- // On row select: copy the clicked row's name into state (drives sibling blocks).
2603
- p = setHandlerSteps(p, sid, `rowSelect:${gridBlock.id}`, [
2604
- { type: 'setVar', name: 'selName', value: { kind: 'field', name: 'name' } },
2605
- ])
2606
- const files = emitStudioProject(p)
2607
- const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
2608
- expect(page).toContain('onRowClick={async (e) => {')
2609
- expect(page).toContain('const row = e.row')
2610
- expect(page).toContain("ctx.state.selName = row?.['name']")
2611
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2612
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2613
- }
2614
- })
2615
-
2616
- it('component on-change event slot wires ctx.<name>.onchange + the wrapper fires change', () => {
2617
- let p = createProject([customers])
2618
- const sid = p.screens[0]!.id
2619
- p = addStateVar(p, sid, { name: 'touched', type: 'boolean' })
2620
- p = addComponentBlock(p, sid, 'button', {})
2621
- const cmp = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'component')!
2622
- p = setHandlerSteps(p, sid, `change:${cmp.id}`, [{ type: 'setVar', name: 'touched', value: { kind: 'literal', value: 'true' } }])
2623
- const files = emitStudioProject(p)
2624
- const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
2625
- const handlers = files.find((f) => f.path.endsWith('/handlers.ts'))!.contents
2626
- expect(page).toContain(".fire('change', e)")
2627
- expect(handlers).toMatch(/ctx\.\w+\.onchange = async \(\) => \{/)
2628
- expect(handlers).toContain('ctx.state.touched = true')
2629
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2630
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2631
- }
2632
- })
2633
-
2634
- it('on-record-saved (formSubmit) steps run inside save() with the submitted row + ctx', () => {
2635
- let p = createProject([customers])
2636
- const sid = p.screens[0]!.id
2637
- // Default CRUD screen edits via a popup form (editing = 'form').
2638
- p = setHandlerSteps(p, sid, 'formSubmit', [
2639
- { type: 'navigate', to: '/customers' },
2640
- ])
2641
- const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
2642
- expect(page).toContain('async function save({ mode, id, values }')
2643
- expect(page).toContain('const row = values')
2644
- expect(page).toContain('const ctx = { grid: gridCtx,')
2645
- expect(page).toContain("ctx.goto('/customers')")
2646
- for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2647
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2648
- }
2649
- })
2650
-
2651
- it('entity triggers compile into the SQL route as createKitHandlers hooks (server-enforced)', () => {
2652
- let p = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
2653
- p = setTrigger(p, 'customers', 'beforeCreate', [
2654
- { type: 'requireField', field: 'name', message: 'Name required' },
2655
- { type: 'setField', field: 'tier', value: { kind: 'literal', value: 'free' } },
2656
- ])
2657
- p = setTrigger(p, 'customers', 'afterUpdate', [{ type: 'code', code: 'console.log("updated", row)' }])
2658
- const route = emitStudioProject(p).find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
2659
- expect(route).toContain('hooks: {')
2660
- expect(route).toContain('beforeCreate: async ({ values }) => {')
2661
- expect(route).toContain('const v = values as Record<string, unknown>')
2662
- expect(route).toContain("if (v['name'] == null || v['name'] === '') throw new Error('Name required')")
2663
- expect(route).toContain("v['tier'] = 'free'")
2664
- expect(route).toContain('afterUpdate: async ({ row }) => {')
2665
- // The route uses createKitHandlers - hooks are a valid option on it.
2666
- expect(route).toContain('createKitHandlers({')
2667
- })
2668
-
2669
- it('a grid-layout screen (default) emits no dock manager', () => {
2670
- const page = emitStudioProject(createProject([customers])).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2671
- expect(page).not.toContain('SvDockManager')
2672
- expect(page).toContain('<div class="st-screen">')
2673
- })
2674
-
2675
- it('a split-layout screen renders a LOCKED SvDockManager (resize-only) and compiles', () => {
2676
- let p = createProject([customers])
2677
- const sid = p.screens[0]!.id
2678
- p = addBlock(p, sid, 'filter')
2679
- p = addBlock(p, sid, 'kpi')
2680
- p = setScreenLayout(p, sid, 'split')
2681
- const files = emitStudioProject(p)
2682
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2683
- // Same SvDockManager plumbing as dock, but locked.
2684
- expect(page).toMatch(/import \{[^}]*SvDockManager[^}]*\} from '@svgrid\/grid'/)
2685
- expect(page).toContain('<SvDockManager bind:workspace={dockWorkspace} onChange={(w) => saveDock(w)} locked>')
2686
- expect(page).toContain('{#snippet pane(p)}')
2687
- for (const b of p.screens[0]!.blocks) expect(page).toContain(`{#if p.id === '${b.id}'}`)
2688
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2689
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2690
- }
2691
- })
2692
-
2693
- it('a stack-layout screen wraps blocks in a single-column flow (.st-stack) and compiles', () => {
2694
- let p = createProject([customers])
2695
- const sid = p.screens[0]!.id
2696
- p = addBlock(p, sid, 'kpi')
2697
- p = setScreenLayout(p, sid, 'stack')
2698
- const files = emitStudioProject(p)
2699
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
2700
- expect(page).toContain('<div class="st-stack">')
2701
- expect(page).not.toContain('<div class="st-screen">')
2702
- expect(page).not.toContain('SvDockManager')
2703
- // Default stack min-height flows into the page-scoped style so blocks aren't tiny.
2704
- expect(page).toContain('.st-stack > * { min-height: 160px; }')
2705
- const css = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
2706
- expect(css).toContain('.st-stack { display: flex; flex-direction: column;')
2707
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
2708
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2709
- }
2710
- })
2711
-
2712
- it('syncDockPanes adds a pane for a new block + strips a removed one, preserving arrangement', () => {
2713
- let p = createProject([customers])
2714
- const sid = p.screens[0]!.id
2715
- p = setScreenLayout(p, sid, 'dock')
2716
- const before = p.screens[0]!.dock!
2717
- const beforeIds = [...dockPaneIds(before)]
2718
- // Add a block: sync appends its pane, keeps the existing ones + the main node id (arrangement).
2719
- p = addBlock(p, sid, 'chart')
2720
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
2721
- const afterAdd = p.screens[0]!.dock!
2722
- const addIds = dockPaneIds(afterAdd)
2723
- for (const id of beforeIds) expect(addIds.has(id)).toBe(true) // existing panes preserved
2724
- const chartId = p.screens[0]!.blocks.find((b) => b.config.kind === 'chart')!.id
2725
- expect(addIds.has(chartId)).toBe(true) // new pane added
2726
- expect(afterAdd.main?.id).toBe(before.main?.id) // main node reused (arrangement not rebuilt)
2727
- // Remove the chart: its pane is stripped, the rest stay.
2728
- p = removeBlock(p, sid, chartId)
2729
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
2730
- const afterRemove = dockPaneIds(p.screens[0]!.dock!)
2731
- expect(afterRemove.has(chartId)).toBe(false)
2732
- for (const id of beforeIds) expect(afterRemove.has(id)).toBe(true)
2733
- })
2734
-
2735
- it('setDockPaneTitle renames a pane (tab text) + dockPaneTitleOf reads it back', () => {
2736
- let p = createProject([customers])
2737
- const sid = p.screens[0]!.id
2738
- p = setScreenLayout(p, sid, 'dock')
2739
- const gridBlock = p.screens[0]!.blocks[0]!
2740
- expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Grid') // auto title
2741
- p = setDockPaneTitle(p, sid, gridBlock.id, 'Customers table')
2742
- expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Customers table')
2743
- // Custom title survives an incremental pane sync (add a block).
2744
- p = addBlock(p, sid, 'chart')
2745
- p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
2746
- expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Customers table')
2747
- })
2748
-
2749
- it('setScreenLayout(dock) seeds a workspace by role + survives serialize/parse', () => {
2750
- let p = createProject([customers])
2751
- const sid = p.screens[0]!.id
2752
- p = addBlock(p, sid, 'filter')
2753
- p = setScreenLayout(p, sid, 'dock')
2754
- const scr = p.screens[0]!
2755
- expect(scr.layout).toBe('dock')
2756
- expect(scr.dock).toBeTruthy()
2757
- // Pane ids equal block ids.
2758
- const paneIds = dockPaneIds(scr.dock!)
2759
- for (const b of scr.blocks) expect(paneIds.has(b.id)).toBe(true)
2760
- // Round-trips through the project (de)serializer.
2761
- const round = parseProject(serializeProject(p))
2762
- expect(round.screens[0]!.layout).toBe('dock')
2763
- expect(round.screens[0]!.dock).toEqual(scr.dock)
2764
- })
2765
- })
2766
-
2767
- describe('emitStudioAppBundle (full runnable app)', () => {
2768
- it('emits a complete SvelteKit + Vite project (scaffolding + screens)', () => {
2769
- const paths = emitStudioAppBundle(createProject([customers, orders], { title: 'My Sales App' })).map((f) => f.path)
2770
- for (const p of [
2771
- 'package.json', 'svelte.config.js', 'vite.config.ts', 'tsconfig.json',
2772
- 'src/app.html', 'src/app.css', 'src/app.d.ts', 'src/routes/+layout.ts', 'README.md',
2773
- 'src/lib/schemas.ts', 'src/lib/data.ts', 'src/routes/+layout.svelte', 'src/routes/customers/+page.svelte',
2774
- ]) {
2775
- expect(paths).toContain(p)
2776
- }
2777
- })
2778
-
2779
- it('package.json has a slugged name, run scripts, and the runtime deps', () => {
2780
- const pkg = JSON.parse(emitStudioAppBundle(createProject([customers], { title: 'My Sales App!' })).find((f) => f.path === 'package.json')!.contents)
2781
- expect(pkg.name).toBe('my-sales-app')
2782
- expect(pkg.scripts.dev).toBe('vite dev')
2783
- expect(pkg.dependencies['@svgrid/grid']).toBeTruthy()
2784
- expect(pkg.dependencies['@svgrid/enterprise']).toBeTruthy()
2785
- expect(pkg.devDependencies['@sveltejs/kit']).toBeTruthy()
2786
- expect(pkg.devDependencies['vite']).toBeTruthy()
2787
- })
2788
-
2789
- it('schedules jobs with the platform: vercel.json crons on Vercel, a workflow elsewhere', () => {
2790
- let base = createProject([customers])
2791
- base = setJob(base, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'code', code: 'void 0' })
2792
-
2793
- const vercel = emitStudioAppBundle(setDeployTarget(base, 'vercel'))
2794
- const vj = vercel.find((f) => f.path === 'vercel.json')!
2795
- expect(JSON.parse(vj.contents).crons).toEqual([{ path: '/api/cron?job=digest', schedule: '0 6 * * *' }])
2796
- // Vercel Cron calls the route itself - no Actions workflow needed.
2797
- expect(vercel.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
2798
-
2799
- const node = emitStudioAppBundle(setDeployTarget(base, 'node'))
2800
- const wf = node.find((f) => f.path === '.github/workflows/cron.yml')!.contents
2801
- expect(wf).toContain("- cron: '0 6 * * *'")
2802
- // The endpoint lives in the CRON_URL secret; the workflow appends the job.
2803
- expect(wf).toContain('"$CRON_URL?job=digest"')
2804
- // Inert until the secrets exist, so CI is green before it is configured.
2805
- expect(wf).toContain("secrets.CRON_URL != ''")
2806
- expect(node.find((f) => f.path === 'vercel.json')).toBeUndefined()
2807
-
2808
- // The secret the route demands is documented in .env.example.
2809
- expect(node.find((f) => f.path === '.env.example')!.contents).toContain('CRON_SECRET=')
2810
- })
2811
-
2812
- it('emits no schedule config when there are no jobs', () => {
2813
- const files = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
2814
- expect(files.find((f) => f.path === 'vercel.json')).toBeUndefined()
2815
- expect(files.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
2816
- })
2817
-
2818
- it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
2819
- const bundle = emitStudioAppBundle(createProject([customers]))
2820
- const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
2821
- // Vite 8's Rolldown bundler crashes in the WebContainer; pin the Rollup-based stack.
2822
- expect(pkg.devDependencies['vite']).toBe('^7.0.0')
2823
- expect(pkg.devDependencies['@sveltejs/vite-plugin-svelte']).toBe('^6.0.0')
2824
- expect(pkg.devDependencies['vitest']).toMatch(/^\^4\./) // vitest 4 supports vite 7
2825
- // engine-strict must not hard-fail install in a sandbox whose Node may differ.
2826
- expect(bundle.find((f) => f.path === '.npmrc')!.contents).toContain('engine-strict=false')
2827
- })
2828
-
2829
- it('ships a per-entity smoke test + vitest wiring', () => {
2830
- const bundle = emitStudioAppBundle(createProject([customers, orders], { title: 'My Sales App' }))
2831
- const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
2832
- expect(pkg.scripts.test).toBe('vitest run')
2833
- expect(pkg.devDependencies['vitest']).toBeTruthy()
2834
- expect(bundle.find((f) => f.path === 'vitest.config.ts')).toBeTruthy()
2835
- const test = bundle.find((f) => f.path === 'src/lib/schemas.test.ts')!.contents
2836
- // one describe block per entity, asserting render + data-source round-trip
2837
- expect(test).toContain('describe("Customer"')
2838
- expect(test).toContain('describe("Order"')
2839
- expect(test).toContain('schemaToColumns(customersSchema)')
2840
- expect(test).toContain('schemaToFormFields(customersSchema)')
2841
- expect(test).toContain('createInMemoryDataSource<Customers>([], customersSchema)')
2842
- expect(test).toContain('round-trips create -> read -> delete')
2843
- expect(test).toContain("from './schemas'")
2844
- })
2845
-
2846
- it('adds the driver dep when an entity binds to Supabase / SQL', () => {
2847
- let sb = createProject([customers])
2848
- sb = setEntityDataSource(sb, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'k' })
2849
- expect(JSON.parse(emitStudioAppBundle(sb).find((f) => f.path === 'package.json')!.contents).dependencies['@supabase/supabase-js']).toBeTruthy()
2850
-
2851
- let sql = createProject([customers])
2852
- sql = setEntityDataSource(sql, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
2853
- expect(JSON.parse(emitStudioAppBundle(sql).find((f) => f.path === 'package.json')!.contents).dependencies['pg']).toBeTruthy()
2854
- })
2855
- })
2856
-
2857
- describe('pages (nav) + shell codegen', () => {
2858
- const layoutOf = (p: Parameters<typeof emitStudioProject>[0]) =>
2859
- emitStudioProject(p).find((f) => f.path === 'src/routes/+layout.svelte')!.contents
2860
-
2861
- it('drops hidden pages from nav and honors label + order', () => {
2862
- let p = createProject([customers, orders])
2863
- p = updateScreen(p, 'orders', { nav: { show: false } })
2864
- p = updateScreen(p, 'customers', { nav: { label: 'Clients', order: 5 } })
2865
- const layout = layoutOf(p)
2866
- expect(layout).toContain('Clients')
2867
- expect(layout).not.toContain('"/orders"') // orders excluded from the nav json
2868
- })
2869
-
2870
- it('emits a top-nav shell with brand + footer', () => {
2871
- const p = setShell(createProject([customers]), { style: 'top-nav', brand: 'Acme', footer: '(c) Acme' })
2872
- const layout = layoutOf(p)
2873
- expect(layout).toContain('sv-app--top')
2874
- expect(layout).toContain('Acme')
2875
- expect(layout).toContain('(c) Acme')
2876
- })
2877
-
2878
- it('emits a bottom-nav shell (fixed bar, no sidebar drawer state, footer text dropped) that compiles', () => {
2879
- const p = setShell(createProject([customers, orders]), { style: 'bottom-nav', brand: 'Acme', footer: '(c) Acme' })
2880
- const layout = layoutOf(p)
2881
- expect(layout).toContain('sv-app--bottom')
2882
- expect(layout).toContain('sv-app__bar--bottom')
2883
- expect(layout).toContain('Acme')
2884
- // The bottom bar occupies that visual role; footer text is intentionally not rendered
2885
- // in the markup (the `const footer = ...` script declaration is unconditional and stays,
2886
- // but nothing in the bottom-nav body dereferences it).
2887
- expect(layout).not.toContain('<footer class="sv-app__footbar">')
2888
- // top-nav/sidebar-only collapse state must not leak into the bottom-nav shell.
2889
- expect(layout).not.toContain('let collapsed = $state')
2890
- expect(() => compile(layout, { filename: '+layout.svelte', generate: 'client' })).not.toThrow()
2891
- })
2892
-
2893
- it('defaults to sidebar; an empty footer omits the footer element', () => {
2894
- const p = setShell(createProject([customers]), { footer: '' })
2895
- const layout = layoutOf(p)
2896
- expect(layout).toContain('sv-app--side')
2897
- expect(layout).not.toContain('class="sv-app__foot"')
2898
- })
2899
-
2900
- it('emits the chosen theme preset tokens into :root', () => {
2901
- const p = setThemePreset(createProject([customers]), 'material')
2902
- const layout = layoutOf(p)
2903
- expect(layout).toContain('--sg-accent: #6750a4') // Material 3 light accent
2904
- expect(layout).toContain('--sg-header-bg: #f3edf7') // Material 3 light surface
2905
- expect(layout).toContain('--sg-radius: 8px')
2906
- })
2907
-
2908
- it('dark mode emits the dark palette + color-scheme: dark', () => {
2909
- const p = setTheme(setThemePreset(createProject([customers]), 'tailwind'), { mode: 'dark' })
2910
- const layout = layoutOf(p)
2911
- expect(layout).toContain('--sg-bg: #0f172a') // Tailwind dark bg
2912
- expect(layout).toContain('color-scheme: dark')
2913
- })
2914
-
2915
- it('an accent override wins over the preset default accent', () => {
2916
- const p = setTheme(setThemePreset(createProject([customers]), 'github'), { accent: '#123456' })
2917
- const layout = layoutOf(p)
2918
- expect(layout).toContain('--sg-accent: #123456')
2919
- expect(layout).not.toContain('--sg-accent: #0969da')
2920
- })
2921
-
2922
- it('custom CSS goes to its own src/custom.css, imported after app.css by the layout', () => {
2923
- const p = setTheme(createProject([customers]), { customCss: '.st__title { letter-spacing: -0.03em; }' })
2924
- const files = emitStudioAppBundle(p)
2925
- const customCss = files.find((f) => f.path === 'src/custom.css')!
2926
- expect(customCss.contents).toContain('.st__title { letter-spacing: -0.03em; }')
2927
- // app.css no longer carries the user's CSS.
2928
- expect(files.find((f) => f.path === 'src/app.css')!.contents).not.toContain('.st__title { letter-spacing: -0.03em; }')
2929
- // The layout imports both, custom.css after app.css so it overrides.
2930
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
2931
- expect(layout.indexOf("import '../custom.css'")).toBeGreaterThan(layout.indexOf("import '../app.css'"))
2932
- // custom.css is always emitted (even empty) so the import never dangles.
2933
- expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === 'src/custom.css')).toBeTruthy()
2934
- })
2935
-
2936
- it('no "Home" nav link; / redirects to the first screen', () => {
2937
- const files = emitStudioProject(createProject([customers, orders]))
2938
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
2939
- // The auto "Home" link is gone (it duplicated the first screen's landing).
2940
- expect(layout).not.toContain('>Home<')
2941
- expect(layout).not.toContain("label: 'Home'")
2942
- // `/` is a redirect to the first navigable screen, not a distinct page.
2943
- const home = files.find((f) => f.path === 'src/routes/+page.svelte')!.contents
2944
- expect(home).toContain('const home = "/customers"')
2945
- expect(home).toContain('goto(home, { replaceState: true })')
2946
- })
2947
-
2948
- it('ships a light/dark switcher: both token sets scoped by [data-theme] + a toggle', () => {
2949
- const p = setThemePreset(createProject([customers]), 'tailwind')
2950
- const layout = layoutOf(p)
2951
- // Both palettes emitted, keyed off <html data-theme>.
2952
- expect(layout).toContain(':root[data-theme="light"]')
2953
- expect(layout).toContain(':root[data-theme="dark"]')
2954
- expect(layout).toContain('--sg-bg: #ffffff') // Tailwind light bg
2955
- expect(layout).toContain('--sg-bg: #0f172a') // Tailwind dark bg
2956
- // The toggle + its runtime are wired in and persist the choice.
2957
- expect(layout).toContain('function toggleTheme()')
2958
- expect(layout).toContain("document.documentElement.dataset.theme")
2959
- expect(layout).toContain("localStorage.setItem('svapp:theme'")
2960
- expect(layout).toContain('sv-app__theme')
2961
- expect(() => compile(layout, { filename: '+layout.svelte', generate: 'client' })).not.toThrow()
2962
- })
2963
- })
2964
-
2965
- describe('code companion (design + your own code)', () => {
2966
- // A blank page with code enabled + a Grid fed by onLoad.
2967
- const build = () => {
2968
- let p = addFreestandingScreen(createProject([customers]), { title: 'Report', route: 'report' })
2969
- const sid = p.screens.find((s) => s.route === 'report')!.id
2970
- p = setScreenRenderGrid(p, sid, true) // implies code enabled
2971
- return { p, sid }
2972
- }
2973
-
2974
- it('emits a user-owned handlers.ts (onLoad) + a regenerated page-context.ts', () => {
2975
- const { p } = build()
2976
- const files = emitStudioProject(p)
2977
- const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')
2978
- expect(companion, 'companion emitted').toBeTruthy()
2979
- expect(companion!.userOwned).toBe(true)
2980
- expect(companion!.contents).toContain('export async function onLoad(ctx: PageContext): Promise<void>')
2981
- expect(companion!.contents).toContain("import type { PageContext } from './page-context'")
2982
- expect(companion!.contents).toContain('never overwrites')
2983
- // The context type + shared handle runtime are generated (not user-owned).
2984
- const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
2985
- expect(ctx.userOwned).toBeFalsy()
2986
- expect(ctx.contents).toContain('export type PageContext')
2987
- expect(files.find((f) => f.path === 'src/lib/handles.svelte.ts')).toBeTruthy()
2988
- })
2989
-
2990
- it('onLoad exists even with no Grid (a general mount hook, not grid-gated)', () => {
2991
- let p = addFreestandingScreen(createProject([customers]), { title: 'Plain', route: 'plain' })
2992
- const sid = p.screens.find((s) => s.route === 'plain')!.id
2993
- p = enableScreenCode(p, sid) // code on, but renderGrid off
2994
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/plain/+page.svelte')!
2995
- expect(emitStudioProject(p).find((f) => f.path === 'src/routes/plain/handlers.ts')!.contents).toContain('export async function onLoad(ctx: PageContext)')
2996
- expect(page.contents).not.toContain('<SvGrid')
2997
- expect(page.contents).toContain('handlers.onLoad(') // still wired on mount
2998
- })
2999
-
3000
- it('the page runs onLoad in onMount, renders the Grid, and compiles', () => {
3001
- const { p } = build()
3002
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/report/+page.svelte')!
3003
- expect(page.contents).toContain("import * as handlers from './handlers'")
3004
- // onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
3005
- // the settable data battery, since this freestanding page owns its rows).
3006
- expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
3007
- expect(page.contents).toContain('as PageContext')
3008
- expect(page.contents).toContain('handlers.onLoad(ctx)')
3009
- expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
3010
- expect(page.contents).toContain('<SvGrid data={rows} columns={columns} features={features}')
3011
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3012
- })
3013
-
3014
- it('the Grid is exposed as its real SvGridApi (ctx.grid), typed to the row in page-context', () => {
3015
- const { p } = build()
3016
- const files = emitStudioProject(p)
3017
- const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
3018
- const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
3019
- expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
3020
- expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
3021
- expect(page.contents).toMatch(/import type \{[^}]*\bSvGridApi\b/)
3022
- // Freestanding data-grid rows are RowData; the grid api is typed to it.
3023
- expect(ctx.contents).toContain('grid: SvGridApi<any, RowData>')
3024
- expect(ctx.contents).toContain("import type { RowData } from '@svgrid/grid'")
3025
- expect(ctx.contents).toMatch(/import type \{[^}]*\bSvGridApi\b/)
3026
- })
3027
-
3028
- it('a dropped component becomes a named, typed handle in code + markup', () => {
3029
- const { p: base, sid } = build()
3030
- const p = addComponentBlock(base, sid, 'button', { variant: 'primary' })
3031
- const files = emitStudioProject(p)
3032
- const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
3033
- const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
3034
- const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
3035
- // Named handle (button1) created + wired into the component markup.
3036
- expect(page.contents).toContain('const button1 = handle(')
3037
- // Click is wired through the component's own onclick callback prop (exact
3038
- // semantics), not a bubbling wrapper listener.
3039
- expect(page.contents).toContain("<SvButton {...button1.props} onclick={(e) => button1.fire('click', e)}>{button1.text}</SvButton>")
3040
- expect(page.contents).toContain("import { handle, gridHandle } from '$lib/handles.svelte'")
3041
- expect(page.contents).toContain('button1.fire(\'click\', e)')
3042
- expect(page.contents).toContain('const ctx = { grid: gridCtx, button1, data:')
3043
- expect(page.contents).toContain('handlers.onLoad(ctx)')
3044
- // Typed handle: ButtonHandle intersects Handle with the button's real setters.
3045
- expect(ctx.contents).toContain('button1: ButtonHandle')
3046
- expect(ctx.contents).toContain('type ButtonHandle = Handle & {')
3047
- expect(ctx.contents).toContain("setVariant(value: \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\"): void")
3048
- // Each prop is ALSO a read/write property (button1.variant = 'danger'), not just a setter.
3049
- expect(ctx.contents).toContain("variant: \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\"")
3050
- expect(ctx.contents).toContain('disabled: boolean')
3051
- expect(ctx.contents).toContain('text: string')
3052
- expect(companion.contents).toContain('ctx.button1')
3053
- expect(companion.contents).not.toContain('getElementById')
3054
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3055
- })
3056
-
3057
- it('Methods panel: visual action steps compile to the ctx handler body + component onclick', () => {
3058
- const { p: base, sid } = build()
3059
- const withGrid = setScreenRenderGrid(base, sid, true) // gives ctx.grid
3060
- let p = addComponentBlock(withGrid, sid, 'button', { _content: 'Export' })
3061
- const btn = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'component')!
3062
- // onLoad steps + the button's on-click steps.
3063
- p = setHandlerSteps(p, sid, 'onLoad', [
3064
- { type: 'gridSort', field: 'name', dir: 'desc' },
3065
- { type: 'setText', target: 'button1', value: 'Download' },
3066
- ])
3067
- p = setHandlerSteps(p, sid, clickSlot(btn.id), [
3068
- { type: 'gridExport', format: 'csv' },
3069
- { type: 'navigate', to: '/other' },
3070
- { type: 'alert', message: 'Done' },
3071
- ])
3072
- const files = emitStudioProject(p)
3073
- const handlers = files.find((f) => f.path === 'src/routes/report/handlers.ts')!.contents
3074
- // onLoad compiled from steps.
3075
- expect(handlers).toContain("ctx.grid.setSort('name', 'desc')")
3076
- expect(handlers).toContain("ctx.button1.text = 'Download'")
3077
- // The button's click steps become an onclick assignment inside onLoad.
3078
- expect(handlers).toContain('ctx.button1.onclick = async () => {')
3079
- expect(handlers).toContain('await ctx.grid.exportCsv()')
3080
- expect(handlers).toContain("ctx.goto('/other')")
3081
- expect(handlers).toContain("alert('Done')")
3082
- // The page runs it + compiles.
3083
- const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
3084
- expect(page.contents).toContain('handlers.onLoad(ctx)')
3085
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
3086
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
3087
- }
3088
- })
3089
-
3090
- it('Methods: stepsToCode drops the visual steps into the raw code editor (take over)', () => {
3091
- const { p: base, sid } = build()
3092
- let p = setHandlerSteps(base, sid, 'onLoad', [{ type: 'alert', message: 'hi' }, { type: 'navigate', to: '/x' }])
3093
- p = stepsToCode(p, sid, 'onLoad')
3094
- const screen = p.screens.find((s) => s.id === sid)!
3095
- expect(screen.handlerSteps?.onLoad).toBeUndefined() // steps cleared
3096
- expect(screen.handlerBodies?.onLoad).toBe("alert('hi')\nctx.goto('/x')") // compiled to raw body
3097
- })
3098
-
3099
- it('Methods: a hand-written onLoad body MERGES with component on-click wiring (neither replaces the other)', () => {
3100
- const { p: base, sid } = build()
3101
- const withGrid = setScreenRenderGrid(base, sid, true)
3102
- let p = addComponentBlock(withGrid, sid, 'button', { _content: 'Go' })
3103
- const btn = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'component')!
3104
- // Hand-written onLoad code AND a button with on-click steps.
3105
- p = setHandlerBody(p, sid, 'onLoad', "console.log('mounted')")
3106
- p = setHandlerSteps(p, sid, clickSlot(btn.id), [{ type: 'navigate', to: '/next' }])
3107
- const handlers = emitStudioProject(p).find((f) => f.path === 'src/routes/report/handlers.ts')!.contents
3108
- expect(handlers).toContain("console.log('mounted')") // the hand-written body survives
3109
- expect(handlers).toContain('ctx.button1.onclick = async () => {') // + the onclick wiring is added
3110
- expect(handlers).toContain("ctx.goto('/next')")
3111
- })
3112
-
3113
- it('handlersSource from the designer is emitted verbatim (advanced override)', () => {
3114
- const { p: base, sid } = build()
3115
- const src = "export async function onLoad() {\n await fetch('/api/report')\n}"
3116
- const p = setScreenHandlersSource(base, sid, src)
3117
- const companion = emitStudioProject(p).find((f) => f.path === 'src/routes/report/handlers.ts')!
3118
- expect(companion.userOwned).toBe(true)
3119
- expect(companion.contents).toContain("await fetch('/api/report')")
3120
- expect(companion.contents).not.toContain('export async function onLoad(ctx') // structured shell replaced
3121
- })
3122
-
3123
- it('no companion and no wiring when the screen has no code', () => {
3124
- const p = addFreestandingScreen(createProject([customers]), { title: 'Bare', route: 'bare' })
3125
- const files = emitStudioProject(p)
3126
- expect(files.find((f) => f.path === 'src/routes/bare/handlers.ts')).toBeUndefined()
3127
- expect(files.find((f) => f.path === 'src/routes/bare/+page.svelte')!.contents).not.toContain("from './handlers'")
3128
- })
3129
-
3130
- it('entity screens wire onLoad/onDestroy + expose their grid as ctx.grid (reload, not setRows)', () => {
3131
- let p = createProject([customers, orders])
3132
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3133
- p = setHandlerBody(p, sid, 'onLoad', 'ctx.grid.autosizeAllColumns()')
3134
- const route = p.screens.find((s) => s.id === sid)!.route
3135
- const files = emitStudioProject(p)
3136
- const page = files.find((f) => f.path === `src/routes/${route}/+page.svelte`)!
3137
- const ctx = files.find((f) => f.path === `src/routes/${route}/page-context.ts`)!
3138
- expect(page.contents).toContain("import * as handlers from './handlers'")
3139
- expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
3140
- expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
3141
- // Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
3142
- expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }, goto, params: Object.fromEntries(page.url.searchParams) } as unknown as PageContext')
3143
- expect(page.contents).toContain('handlers.onLoad(ctx)')
3144
- expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
3145
- // The grid api is typed to the entity's row (Customers), not any.
3146
- expect(ctx.contents).toContain('grid: SvGridApi<any, Customers>')
3147
- expect(ctx.contents).toContain("import type { Customers } from '$lib/schemas'")
3148
- expect(ctx.contents).not.toContain('setRows: (rows') // the entity grid is controller-fed, no setRows member
3149
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3150
- })
3151
-
3152
- it('data-viz blocks become setData handles; entity components + batteries are in ctx', () => {
3153
- let p = createProject([customers, orders])
3154
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3155
- // A chart + a KPI + a button on the customers screen, with code enabled.
3156
- p = addBlock(p, sid, 'chart')
3157
- p = addBlock(p, sid, 'kpi')
3158
- p = addComponentBlock(p, sid, 'button', { variant: 'primary' })
3159
- p = enableScreenCode(p, sid)
3160
- const route = p.screens.find((s) => s.id === sid)!.route
3161
- const files = emitStudioProject(p)
3162
- const page = files.find((f) => f.path === `src/routes/${route}/+page.svelte`)!
3163
- const ctx = files.find((f) => f.path === `src/routes/${route}/page-context.ts`)!
3164
- // Per-block DataHandles declared and fed to the chart/kpi markup.
3165
- expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
3166
- expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
3167
- expect(page.contents).toContain('rows={chart1.rows}')
3168
- expect(page.contents).toContain("import { handle, dataHandle, gridHandle } from '$lib/handles.svelte'")
3169
- // ctx carries the data handles + the entity component handle + batteries.
3170
- expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
3171
- expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')
3172
- expect(ctx.contents).toContain('button1: ButtonHandle')
3173
- expect(ctx.contents).toContain('goto: (path: string) => void')
3174
- expect(ctx.contents).toContain('params: Record<string, string>')
3175
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3176
- })
3177
-
3178
- it('emits the shared DataHandle runtime, and its private-$state pattern is valid Svelte', () => {
3179
- let p = createProject([customers])
3180
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3181
- p = addBlock(p, sid, 'chart') // needs a DataHandle
3182
- p = addComponentBlock(p, sid, 'button', {}) // needs a ComponentHandle
3183
- p = enableScreenCode(p, sid)
3184
- const mod = emitStudioProject(p).find((f) => f.path === 'src/lib/handles.svelte.ts')!
3185
- expect(mod.contents).toContain('export class DataHandle')
3186
- expect(mod.contents).toContain('setData(rows: T[]): void')
3187
- // The generated .svelte.ts is stripped of types + compiled by the bundler's
3188
- // svelte plugin; here we prove the reactive private-field pattern it relies on
3189
- // (a `#field = $state()` read through a getter) is valid Svelte 5 via the
3190
- // component compiler (which strips TS in `<script lang="ts">`).
3191
- const probe = `<script lang="ts">
3192
- class DataHandle<T> {
3193
- #override = $state<T[] | null>(null)
3194
- #fallback: () => T[]
3195
- constructor(fallback: () => T[]) { this.#fallback = fallback }
3196
- get rows(): T[] { return this.#override ?? this.#fallback() }
3197
- setData(rows: T[]): void { this.#override = rows }
3198
- clear(): void { this.#override = null }
3199
- }
3200
- let all = $state<number[]>([1, 2])
3201
- const h = new DataHandle<number>(() => all)
3202
- </script>
3203
- <p>{h.rows.length}</p>`
3204
- expect(() => compile(probe, { filename: 'probe.svelte', generate: 'client' })).not.toThrow()
3205
- })
3206
-
3207
- it('ctxCompletions exposes the FULL grid api + every handle member + batteries', () => {
3208
- let p = createProject([customers])
3209
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3210
- p = addBlock(p, sid, 'chart')
3211
- p = addComponentBlock(p, sid, 'button', {})
3212
- p = enableScreenCode(p, sid)
3213
- const screen = p.screens.find((s) => s.id === sid)!
3214
- const comp = ctxCompletions(screen)
3215
- // The whole grid surface, not a handful.
3216
- expect(comp).toContain('ctx.grid.exportCsv()')
3217
- expect(comp).toContain('ctx.grid.autosizeAllColumns()')
3218
- expect(comp).toContain('ctx.grid.applyTransaction()')
3219
- expect(comp.filter((c) => c.startsWith('ctx.grid.')).length).toBeGreaterThan(40)
3220
- // Data handle + typed component setters + batteries.
3221
- expect(comp).toContain('ctx.chart1.setData()')
3222
- expect(comp).toContain('ctx.chart1.rows')
3223
- expect(comp).toContain('ctx.button1.setVariant()')
3224
- expect(comp).toContain('ctx.button1.onclick')
3225
- expect(comp).toContain('ctx.data.reload()')
3226
- expect(comp).toContain('ctx.goto()')
3227
- expect(comp).toContain('ctx.params')
3228
- })
3229
-
3230
- it('per-block style overrides are emitted as inline CSS on the block wrapper', () => {
3231
- let p = createProject([customers])
3232
- const sid = p.screens[0]!.id
3233
- // Give the grid block a border-off + padding + margin override.
3234
- const bid = p.screens[0]!.blocks[0]!.id
3235
- p = updateBlock(p, sid, bid, { style: { border: false, padding: 24, margin: 8, radius: 10, background: '#fafafa' } })
3236
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
3237
- expect(page.contents).toContain('border: none')
3238
- expect(page.contents).toContain('padding: 24px')
3239
- expect(page.contents).toContain('margin: 8px')
3240
- expect(page.contents).toContain('border-radius: 10px')
3241
- expect(page.contents).toContain('background: #fafafa')
3242
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3243
- })
3244
-
3245
- it('per-block className is emitted on the wrapper (merged with base classes) + sanitized', () => {
3246
- let p = createProject([customers])
3247
- const sid = p.screens[0]!.id
3248
- p = addBlock(p, sid, 'kpi')
3249
- const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
3250
- const kpiId = p.screens[0]!.blocks.find((b) => b.config.kind === 'kpi')!.id
3251
- p = updateBlock(p, sid, gridId, { className: 'my-grid highlight' })
3252
- p = updateBlock(p, sid, kpiId, { className: 'evil"onload=x' }) // sanitized
3253
- const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
3254
- expect(page.contents).toContain('class="my-grid highlight"')
3255
- // kpi keeps its base class, appends the (sanitized) user class - no attribute break-out.
3256
- expect(page.contents).toContain('class="kpi evilonloadx"')
3257
- expect(page.contents).not.toContain('onload=x')
3258
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3259
- })
3260
-
3261
- it('per-screen className lands on .st-screen; app className lands on the shell root', () => {
3262
- let p = createProject([customers])
3263
- const sid = p.screens[0]!.id
3264
- p = updateScreen(p, sid, { className: 'crm-screen' })
3265
- p = setTheme(p, { appClass: 'brand-app' })
3266
- const files = emitStudioProject(p)
3267
- const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!
3268
- expect(page.contents).toContain('<div class="st-screen crm-screen">')
3269
- const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!
3270
- expect(layout.contents).toMatch(/class="sv-app sv-app--\w+ brand-app"/)
3271
- expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
3272
- expect(() => compile(layout.contents, { filename: layout.path, generate: 'client' })).not.toThrow()
3273
- })
3274
-
3275
- it('ctxAmbientDts type-checks real ctx usage and catches grid/data typos (editor TS surface)', () => {
3276
- let p = createProject([customers])
3277
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3278
- p = addBlock(p, sid, 'chart')
3279
- p = addComponentBlock(p, sid, 'button', {})
3280
- p = enableScreenCode(p, sid)
3281
- const screen = p.screens.find((s) => s.id === sid)!
3282
- const dts = ctxAmbientDts(screen, customers)
3283
- // Valid, real code compiles clean: awaited grid export, feed a chart from the
3284
- // dataset, type a component setter, navigate, read a param.
3285
- expect(typeCheckBody(dts, [
3286
- 'const csv = await ctx.grid.exportCsv()',
3287
- 'console.log(csv.length)',
3288
- 'ctx.chart1.setData(ctx.data.rows)',
3289
- "ctx.button1.setVariant('danger')",
3290
- "ctx.button1.variant = 'ghost'", // prop assignment, not just the setter
3291
- 'ctx.button1.disabled = true',
3292
- "ctx.button1.text = 'Save'",
3293
- "ctx.goto('/orders')",
3294
- 'const id = ctx.params.id',
3295
- 'ctx.grid.setSort("mrr", "desc")',
3296
- ].join('\n'))).toEqual([])
3297
- // Assigning the wrong type to a typed prop is caught.
3298
- expect(typeCheckBody(dts, "ctx.button1.variant = 'nope'").length).toBeGreaterThan(0)
3299
- expect(typeCheckBody(dts, 'ctx.button1.disabled = 5').length).toBeGreaterThan(0)
3300
- // A misspelled grid method is a hard error (SvGridApi is precisely typed).
3301
- const gridTypo = typeCheckBody(dts, 'ctx.grid.exprtCsv()')
3302
- expect(gridTypo.length).toBeGreaterThan(0)
3303
- expect(gridTypo.join(' ')).toMatch(/exprtCsv/)
3304
- // Feeding a chart the wrong element type is caught (DataHandle<Customers>).
3305
- expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
3306
- // An unknown ctx member is caught.
3307
- expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
3308
- // This test runs REAL TypeScript programs over the generated code against
3309
- // the grid's .d.ts, so it scales with the public type surface and is far
3310
- // slower than a normal unit test. It passes comfortably in isolation but was
3311
- // brushing the default 5s budget under full-suite parallel load; every grid
3312
- // API added since made that worse. Give the compile room.
3313
- }, 30_000)
3314
-
3315
- it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
3316
- let p = createProject([customers])
3317
- const sid = p.screens.find((s) => s.entity === 'customers')!.id
3318
- p = setHandlerBody(p, sid, 'onDestroy', 'clearInterval(timer)')
3319
- const route = p.screens.find((s) => s.id === sid)!.route
3320
- const companion = emitStudioProject(p).find((f) => f.path === `src/routes/${route}/handlers.ts`)!
3321
- expect(companion.contents).toContain('export function onDestroy(ctx: PageContext): void')
3322
- expect(companion.contents).toContain('clearInterval(timer)')
3323
- })
3324
- })