@svgrid/enterprise 2.5.2 → 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.
- package/dist/SvAlertRuleEditor.svelte.d.ts +13 -0
- package/dist/SvAlertsManager.svelte.d.ts +14 -0
- package/dist/SvAlertsPanel.svelte.d.ts +9 -0
- package/dist/SvAuthGate.svelte.d.ts +22 -0
- package/dist/SvBoard.svelte.d.ts +35 -0
- package/dist/SvExportMenu.svelte.d.ts +61 -0
- package/dist/SvExpressionEditor.svelte.d.ts +12 -0
- package/dist/SvFileInput.svelte.d.ts +20 -0
- package/dist/SvGridAlerts.svelte.d.ts +77 -0
- package/dist/SvGridBoard.svelte.d.ts +30 -0
- package/dist/SvGridEditPanel.svelte.d.ts +73 -0
- package/dist/SvGridMasterDetail.svelte.d.ts +49 -0
- package/dist/SvGridScheduler.svelte.d.ts +30 -0
- package/dist/SvImportDialog.svelte.d.ts +88 -0
- package/dist/SvLookupInput.svelte.d.ts +21 -0
- package/dist/SvPivotDesigner.svelte.d.ts +176 -0
- package/dist/SvRecordDetail.svelte.d.ts +58 -0
- package/dist/SvSchedule.svelte.d.ts +29 -0
- package/dist/SvSchemaChart.svelte.d.ts +61 -0
- package/dist/SvSchemaDashboard.svelte.d.ts +46 -0
- package/dist/alerts/alert-engine-attach.d.ts +79 -0
- package/dist/alerts/alert-engine-attach.js +106 -0
- package/dist/alerts/alert-engine.d.ts +42 -0
- package/dist/alerts/alert-engine.js +196 -0
- package/dist/alerts/alert-formats.d.ts +26 -0
- package/dist/alerts/alert-formats.js +58 -0
- package/dist/alerts/alert-observer.d.ts +54 -0
- package/dist/alerts/alert-observer.js +156 -0
- package/dist/alerts/alert-scheduler.d.ts +30 -0
- package/dist/alerts/alert-scheduler.js +54 -0
- package/dist/alerts/alert-storage.d.ts +31 -0
- package/dist/alerts/alert-storage.js +92 -0
- package/dist/alerts/alert-store.svelte.d.ts +24 -0
- package/dist/alerts/alert-store.svelte.js +69 -0
- package/dist/alerts/alert-types.d.ts +86 -0
- package/dist/alerts/alert-types.js +1 -0
- package/dist/alerts.d.ts +2 -0
- package/{src/alerts.ts → dist/alerts.js} +9 -9
- package/dist/board.d.ts +7 -0
- package/{src/board.ts → dist/board.js} +13 -14
- package/dist/cdn/svgrid-enterprise.svelte-external.js +2987 -2981
- package/dist/designer/assets/{index-CM5xvaOy.js → index-BF0UH638.js} +3 -3
- package/dist/designer/assets/{src-D-ET9Mmo.js → src-BPJruwB9.js} +184 -184
- package/dist/designer/index.html +2 -2
- package/dist/edit-panel.d.ts +105 -0
- package/dist/edit-panel.js +350 -0
- package/dist/export-conditional.d.ts +31 -0
- package/dist/export-conditional.js +65 -0
- package/dist/export-ooxml.d.ts +93 -0
- package/dist/export-ooxml.js +385 -0
- package/dist/export-pdf.d.ts +107 -0
- package/dist/export-pdf.js +144 -0
- package/dist/export-print.d.ts +55 -0
- package/dist/export-print.js +91 -0
- package/{src/export-serialize.ts → dist/export-serialize.d.ts} +11 -24
- package/dist/export-serialize.js +11 -0
- package/dist/export-xls.d.ts +26 -0
- package/dist/export-xls.js +157 -0
- package/dist/export.d.ts +323 -0
- package/dist/export.js +1021 -0
- package/dist/expressions/evaluate.d.ts +17 -0
- package/dist/expressions/evaluate.js +214 -0
- package/dist/expressions/expression-columns.d.ts +61 -0
- package/dist/expressions/expression-columns.js +72 -0
- package/dist/expressions/expression-types.d.ts +125 -0
- package/dist/expressions/expression-types.js +1 -0
- package/dist/expressions/parse.d.ts +25 -0
- package/dist/expressions/parse.js +537 -0
- package/dist/import.d.ts +232 -0
- package/dist/import.js +846 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +91 -0
- package/dist/install.d.ts +70 -0
- package/dist/install.js +61 -0
- package/dist/license-core.d.ts +37 -0
- package/dist/license-core.js +81 -0
- package/dist/license.d.ts +30 -0
- package/dist/license.js +139 -0
- package/dist/master-detail.d.ts +23 -0
- package/dist/master-detail.js +35 -0
- package/dist/node/studio.js +1 -1
- package/dist/pivot-chart.d.ts +28 -0
- package/dist/pivot-chart.js +71 -0
- package/dist/pivot-designer.d.ts +69 -0
- package/dist/pivot-designer.js +38 -0
- package/dist/pivot-enable.d.ts +6 -0
- package/{src/pivot-enable.ts → dist/pivot-enable.js} +13 -18
- package/dist/pivot.d.ts +111 -0
- package/dist/pivot.js +353 -0
- package/dist/print.d.ts +33 -0
- package/dist/print.js +61 -0
- package/dist/revoked.d.ts +1 -0
- package/{src/revoked.ts → dist/revoked.js} +5 -6
- package/dist/scheduler-assignments.d.ts +64 -0
- package/dist/scheduler-assignments.js +92 -0
- package/dist/scheduler-axis.d.ts +81 -0
- package/dist/scheduler-axis.js +180 -0
- package/{src/scheduler-booking.ts → dist/scheduler-booking.d.ts} +10 -43
- package/dist/scheduler-booking.js +50 -0
- package/dist/scheduler-config.d.ts +177 -0
- package/dist/scheduler-config.js +1 -0
- package/dist/scheduler-dependencies.d.ts +89 -0
- package/dist/scheduler-dependencies.js +179 -0
- package/dist/scheduler-freebusy.d.ts +19 -0
- package/{src/scheduler-freebusy.ts → dist/scheduler-freebusy.js} +9 -19
- package/{src/scheduler-heatmap.ts → dist/scheduler-heatmap.d.ts} +12 -35
- package/dist/scheduler-heatmap.js +41 -0
- package/dist/scheduler-resource-tree.d.ts +48 -0
- package/dist/scheduler-resource-tree.js +78 -0
- package/dist/scheduler-slots.d.ts +37 -0
- package/dist/scheduler-slots.js +80 -0
- package/dist/scheduler-summary.d.ts +41 -0
- package/dist/scheduler-summary.js +54 -0
- package/dist/scheduler.d.ts +7 -0
- package/{src/scheduler.ts → dist/scheduler.js} +13 -14
- package/dist/scheduling.d.ts +132 -0
- package/dist/scheduling.js +228 -0
- package/dist/schema-designer.d.ts +42 -0
- package/dist/schema-designer.js +117 -0
- package/dist/schema.d.ts +369 -0
- package/dist/schema.js +216 -0
- package/dist/smart-shim.d.ts +62 -0
- package/dist/smart-shim.js +34 -0
- package/dist/sources/aggregate.d.ts +43 -0
- package/dist/sources/aggregate.js +60 -0
- package/dist/sources/auth-supabase.d.ts +69 -0
- package/dist/sources/auth-supabase.js +39 -0
- package/dist/sources/dashboard.d.ts +65 -0
- package/dist/sources/dashboard.js +95 -0
- package/dist/sources/field-inference.d.ts +23 -0
- package/dist/sources/field-inference.js +52 -0
- package/dist/sources/filters.d.ts +35 -0
- package/dist/sources/filters.js +52 -0
- package/dist/sources/index.d.ts +18 -0
- package/dist/sources/index.js +18 -0
- package/dist/sources/introspect-supabase.d.ts +30 -0
- package/dist/sources/introspect-supabase.js +134 -0
- package/dist/sources/realtime-supabase.d.ts +37 -0
- package/dist/sources/realtime-supabase.js +52 -0
- package/dist/sources/relation-lookup.d.ts +50 -0
- package/dist/sources/relation-lookup.js +77 -0
- package/dist/sources/rest-adapters.d.ts +48 -0
- package/dist/sources/rest-adapters.js +85 -0
- package/dist/sources/rest.d.ts +38 -0
- package/dist/sources/rest.js +86 -0
- package/dist/sources/schema-from-columns.d.ts +37 -0
- package/dist/sources/schema-from-columns.js +48 -0
- package/dist/sources/supabase.d.ts +33 -0
- package/dist/sources/supabase.js +74 -0
- package/dist/sources/with-entity-rules.d.ts +25 -0
- package/dist/sources/with-entity-rules.js +47 -0
- package/dist/sources/with-relation-labels.d.ts +26 -0
- package/dist/sources/with-relation-labels.js +38 -0
- package/dist/staged-editing.d.ts +85 -0
- package/dist/staged-editing.js +0 -0
- package/dist/studio/bug-report.d.ts +61 -0
- package/dist/studio/bug-report.js +126 -0
- package/dist/studio/cli.d.ts +56 -0
- package/dist/studio/cli.js +70 -0
- package/dist/studio/copilot-core.d.ts +22 -0
- package/{src/studio/copilot-core.ts → dist/studio/copilot-core.js} +34 -38
- package/dist/studio/csv.d.ts +36 -0
- package/dist/studio/csv.js +173 -0
- package/dist/studio/db-connect-string.d.ts +45 -0
- package/dist/studio/db-connect-string.js +113 -0
- package/dist/studio/deploy-cli.d.ts +35 -0
- package/dist/studio/deploy-cli.js +81 -0
- package/dist/studio/emit-project.d.ts +77 -0
- package/dist/studio/emit-project.js +5389 -0
- package/dist/studio/emit-schema.d.ts +110 -0
- package/dist/studio/emit-schema.js +1128 -0
- package/dist/studio/index.d.ts +35 -0
- package/dist/studio/index.js +38 -0
- package/dist/studio/init-flow.d.ts +58 -0
- package/dist/studio/init-flow.js +451 -0
- package/dist/studio/introspect-db.d.ts +47 -0
- package/dist/studio/introspect-db.js +255 -0
- package/dist/studio/introspect-openapi.d.ts +19 -0
- package/dist/studio/introspect-openapi.js +244 -0
- package/dist/studio/introspect-prisma.d.ts +25 -0
- package/dist/studio/introspect-prisma.js +164 -0
- package/dist/studio/introspect.d.ts +35 -0
- package/dist/studio/introspect.js +306 -0
- package/dist/studio/project.d.ts +1327 -0
- package/dist/studio/project.js +1798 -0
- package/dist/studio/sample-data.d.ts +19 -0
- package/dist/studio/sample-data.js +207 -0
- package/dist/studio/samples/ats.d.ts +2 -0
- package/dist/studio/samples/ats.js +194 -0
- package/dist/studio/samples/clinic.d.ts +2 -0
- package/dist/studio/samples/clinic.js +171 -0
- package/dist/studio/samples/crm.d.ts +2 -0
- package/dist/studio/samples/crm.js +280 -0
- package/dist/studio/samples/datasets.d.ts +38 -0
- package/dist/studio/samples/datasets.js +296 -0
- package/dist/studio/samples/ecommerce.d.ts +2 -0
- package/dist/studio/samples/ecommerce.js +178 -0
- package/dist/studio/samples/events.d.ts +2 -0
- package/dist/studio/samples/events.js +192 -0
- package/dist/studio/samples/fleet.d.ts +2 -0
- package/dist/studio/samples/fleet.js +203 -0
- package/dist/studio/samples/gym.d.ts +2 -0
- package/dist/studio/samples/gym.js +205 -0
- package/dist/studio/samples/hr.d.ts +2 -0
- package/dist/studio/samples/hr.js +186 -0
- package/dist/studio/samples/index.d.ts +13 -0
- package/dist/studio/samples/index.js +44 -0
- package/dist/studio/samples/insurance.d.ts +2 -0
- package/dist/studio/samples/insurance.js +220 -0
- package/dist/studio/samples/inventory.d.ts +2 -0
- package/dist/studio/samples/inventory.js +204 -0
- package/dist/studio/samples/invoicing.d.ts +2 -0
- package/dist/studio/samples/invoicing.js +154 -0
- package/dist/studio/samples/library.d.ts +2 -0
- package/dist/studio/samples/library.js +171 -0
- package/dist/studio/samples/live-data.d.ts +5 -0
- package/dist/studio/samples/live-data.js +261 -0
- package/dist/studio/samples/projects.d.ts +2 -0
- package/dist/studio/samples/projects.js +203 -0
- package/dist/studio/samples/realestate.d.ts +2 -0
- package/dist/studio/samples/realestate.js +187 -0
- package/dist/studio/samples/restaurant.d.ts +2 -0
- package/dist/studio/samples/restaurant.js +180 -0
- package/dist/studio/samples/school.d.ts +2 -0
- package/dist/studio/samples/school.js +188 -0
- package/dist/studio/samples/shared.d.ts +142 -0
- package/dist/studio/samples/shared.js +164 -0
- package/dist/studio/samples/starter.d.ts +4 -0
- package/dist/studio/samples/starter.js +237 -0
- package/dist/studio/samples/subscriptions.d.ts +2 -0
- package/dist/studio/samples/subscriptions.js +175 -0
- package/dist/studio/samples/support.d.ts +2 -0
- package/dist/studio/samples/support.js +209 -0
- package/dist/studio/scaffold-app.d.ts +22 -0
- package/{src/studio/scaffold-app.ts → dist/studio/scaffold-app.js} +45 -63
- package/dist/studio/scaffold.d.ts +63 -0
- package/dist/studio/scaffold.js +346 -0
- package/dist/studio/screen-suites.d.ts +193 -0
- package/dist/studio/screen-suites.js +297 -0
- package/dist/studio/themes.d.ts +34 -0
- package/dist/studio/themes.js +41 -0
- package/dist/studio/ui-components.d.ts +89 -0
- package/dist/studio/ui-components.generated.d.ts +29 -0
- package/dist/studio/ui-components.generated.js +7139 -0
- package/dist/studio/ui-components.js +673 -0
- package/dist/studio/user-error.d.ts +23 -0
- package/{src/studio/user-error.ts → dist/studio/user-error.js} +10 -13
- package/dist/studio/verify.d.ts +34 -0
- package/dist/studio/verify.js +47 -0
- package/dist/sveltekit/in-memory.d.ts +14 -0
- package/dist/sveltekit/in-memory.js +113 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +5 -0
- package/dist/sveltekit/query-plan.d.ts +44 -0
- package/dist/sveltekit/query-plan.js +76 -0
- package/dist/sveltekit/sql-source.d.ts +47 -0
- package/dist/sveltekit/sql-source.js +106 -0
- package/dist/sveltekit/sql.d.ts +48 -0
- package/dist/sveltekit/sql.js +73 -0
- package/dist/sveltekit/transport.d.ts +209 -0
- package/dist/sveltekit/transport.js +219 -0
- package/{src/sveltekit/types.ts → dist/sveltekit/types.d.ts} +8 -11
- package/dist/sveltekit/types.js +1 -0
- package/dist/upgrade-prompt.d.ts +16 -0
- package/dist/upgrade-prompt.js +141 -0
- package/dist/version.d.ts +13 -0
- package/{src/version.ts → dist/version.js} +1 -1
- package/dist/watermark.d.ts +2 -0
- package/dist/watermark.js +146 -0
- package/package.json +26 -24
- package/src/SvBoard.dom.test.ts +0 -67
- package/src/SvGridAlerts.dom.test.ts +0 -113
- package/src/SvGridEditPanel.dom.test.ts +0 -320
- package/src/SvRecordDetail.dom.test.ts +0 -137
- package/src/SvSchedule.dom.test.ts +0 -57
- package/src/ai-export-pdf.dom.test.ts +0 -77
- package/src/ai-export-xlsx.dom.test.ts +0 -90
- package/src/ai-export.dom.test.ts +0 -114
- package/src/alerts/alert-engine-attach.ts +0 -165
- package/src/alerts/alert-engine.test.ts +0 -135
- package/src/alerts/alert-engine.ts +0 -260
- package/src/alerts/alert-formats.test.ts +0 -87
- package/src/alerts/alert-formats.ts +0 -77
- package/src/alerts/alert-observer.test.ts +0 -189
- package/src/alerts/alert-observer.ts +0 -208
- package/src/alerts/alert-scheduler.ts +0 -96
- package/src/alerts/alert-storage.test.ts +0 -54
- package/src/alerts/alert-storage.ts +0 -116
- package/src/alerts/alert-store.svelte.ts +0 -80
- package/src/alerts/alert-types.ts +0 -94
- package/src/board.dom.test.ts +0 -941
- package/src/edit-panel.test.ts +0 -322
- package/src/edit-panel.ts +0 -379
- package/src/export-conditional.test.ts +0 -60
- package/src/export-conditional.ts +0 -94
- package/src/export-ooxml.test.ts +0 -152
- package/src/export-ooxml.ts +0 -489
- package/src/export-pdf.test.ts +0 -138
- package/src/export-pdf.ts +0 -245
- package/src/export-print.test.ts +0 -66
- package/src/export-print.ts +0 -132
- package/src/export-serialize.test.ts +0 -56
- package/src/export-xls.ts +0 -199
- package/src/export-xlsx-roundtrip.test.ts +0 -120
- package/src/export-xlsx.test.ts +0 -150
- package/src/export.test.ts +0 -349
- package/src/export.ts +0 -1464
- package/src/expressions/evaluate.test.ts +0 -111
- package/src/expressions/evaluate.ts +0 -237
- package/src/expressions/expression-columns.ts +0 -133
- package/src/expressions/expression-types.ts +0 -84
- package/src/expressions/parse.test.ts +0 -106
- package/src/expressions/parse.ts +0 -610
- package/src/import-automap.test.ts +0 -131
- package/src/import-hardening.test.ts +0 -158
- package/src/import.test.ts +0 -415
- package/src/import.ts +0 -1044
- package/src/index.ts +0 -569
- package/src/install.ts +0 -154
- package/src/license-core.test.ts +0 -23
- package/src/license-core.ts +0 -107
- package/src/license-expiry.test.ts +0 -127
- package/src/license.ts +0 -137
- package/src/master-detail.test.ts +0 -61
- package/src/master-detail.ts +0 -42
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js +0 -3875
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js +0 -21
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js +0 -1938
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js +0 -205
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js +0 -18
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js +0 -264
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js +0 -697
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js +0 -4938
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js +0 -495
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js +0 -190
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js +0 -8
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js.map +0 -1
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js +0 -138
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js.map +0 -1
- package/src/pivot-chart.test.ts +0 -86
- package/src/pivot-chart.ts +0 -112
- package/src/pivot-designer.ts +0 -101
- package/src/pivot.test.ts +0 -335
- package/src/pivot.ts +0 -578
- package/src/print.ts +0 -107
- package/src/scheduler-assignments.test.ts +0 -97
- package/src/scheduler-assignments.ts +0 -134
- package/src/scheduler-axis.test.ts +0 -108
- package/src/scheduler-axis.ts +0 -238
- package/src/scheduler-booking.test.ts +0 -57
- package/src/scheduler-config.ts +0 -179
- package/src/scheduler-dependencies.test.ts +0 -155
- package/src/scheduler-dependencies.ts +0 -223
- package/src/scheduler-freebusy.test.ts +0 -41
- package/src/scheduler-heatmap.test.ts +0 -39
- package/src/scheduler-resource-tree.test.ts +0 -84
- package/src/scheduler-resource-tree.ts +0 -107
- package/src/scheduler-slots.test.ts +0 -53
- package/src/scheduler-slots.ts +0 -94
- package/src/scheduler-summary.test.ts +0 -47
- package/src/scheduler-summary.ts +0 -81
- package/src/scheduling.test.ts +0 -194
- package/src/scheduling.ts +0 -293
- package/src/schema-designer.test.ts +0 -121
- package/src/schema-designer.ts +0 -142
- package/src/schema.test.ts +0 -268
- package/src/schema.ts +0 -562
- package/src/smart-shim.ts +0 -107
- package/src/sources/aggregate.test.ts +0 -79
- package/src/sources/aggregate.ts +0 -102
- package/src/sources/auth-supabase.test.ts +0 -80
- package/src/sources/auth-supabase.ts +0 -87
- package/src/sources/dashboard.kpi.test.ts +0 -50
- package/src/sources/dashboard.test.ts +0 -61
- package/src/sources/dashboard.ts +0 -136
- package/src/sources/field-inference.test.ts +0 -60
- package/src/sources/field-inference.ts +0 -63
- package/src/sources/filters.test.ts +0 -58
- package/src/sources/filters.ts +0 -70
- package/src/sources/index.ts +0 -67
- package/src/sources/introspect-supabase.test.ts +0 -112
- package/src/sources/introspect-supabase.ts +0 -139
- package/src/sources/realtime-supabase.test.ts +0 -93
- package/src/sources/realtime-supabase.ts +0 -99
- package/src/sources/relation-lookup.test.ts +0 -99
- package/src/sources/relation-lookup.ts +0 -128
- package/src/sources/rest-adapters.test.ts +0 -95
- package/src/sources/rest-adapters.ts +0 -120
- package/src/sources/rest.test.ts +0 -104
- package/src/sources/rest.ts +0 -129
- package/src/sources/schema-from-columns.test.ts +0 -106
- package/src/sources/schema-from-columns.ts +0 -88
- package/src/sources/supabase.test.ts +0 -110
- package/src/sources/supabase.ts +0 -99
- package/src/sources/with-entity-rules.test.ts +0 -104
- package/src/sources/with-entity-rules.ts +0 -78
- package/src/sources/with-relation-labels.test.ts +0 -75
- package/src/sources/with-relation-labels.ts +0 -73
- package/src/staged-editing.ts +0 -0
- package/src/studio/bug-report.test.ts +0 -101
- package/src/studio/bug-report.ts +0 -165
- package/src/studio/cli.test.ts +0 -187
- package/src/studio/cli.ts +0 -117
- package/src/studio/copilot-core.test.ts +0 -45
- package/src/studio/csv.test.ts +0 -90
- package/src/studio/csv.ts +0 -165
- package/src/studio/db-connect-string.test.ts +0 -94
- package/src/studio/db-connect-string.ts +0 -128
- package/src/studio/deploy-cli.test.ts +0 -56
- package/src/studio/deploy-cli.ts +0 -100
- package/src/studio/emit-project.test.ts +0 -3324
- package/src/studio/emit-project.ts +0 -5377
- package/src/studio/emit-schema.test.ts +0 -111
- package/src/studio/emit-schema.ts +0 -1181
- package/src/studio/index.ts +0 -380
- package/src/studio/init-flow.test.ts +0 -514
- package/src/studio/init-flow.ts +0 -556
- package/src/studio/introspect-db.test.ts +0 -186
- package/src/studio/introspect-db.ts +0 -312
- package/src/studio/introspect-openapi.test.ts +0 -84
- package/src/studio/introspect-openapi.ts +0 -252
- package/src/studio/introspect-prisma.test.ts +0 -99
- package/src/studio/introspect-prisma.ts +0 -175
- package/src/studio/introspect.test.ts +0 -172
- package/src/studio/introspect.ts +0 -310
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
- package/src/studio/pipeline.test.ts +0 -42
- package/src/studio/project-robust.test.ts +0 -157
- package/src/studio/project.test.ts +0 -929
- package/src/studio/project.ts +0 -2394
- package/src/studio/sample-data.test.ts +0 -58
- package/src/studio/sample-data.ts +0 -203
- package/src/studio/samples/ats.ts +0 -202
- package/src/studio/samples/clinic.ts +0 -179
- package/src/studio/samples/crm.ts +0 -291
- package/src/studio/samples/datasets.test.ts +0 -84
- package/src/studio/samples/datasets.ts +0 -340
- package/src/studio/samples/ecommerce.ts +0 -187
- package/src/studio/samples/events.ts +0 -201
- package/src/studio/samples/fleet.ts +0 -213
- package/src/studio/samples/gym.ts +0 -215
- package/src/studio/samples/hr.ts +0 -195
- package/src/studio/samples/index.ts +0 -55
- package/src/studio/samples/insurance.ts +0 -229
- package/src/studio/samples/inventory.ts +0 -213
- package/src/studio/samples/invoicing.ts +0 -162
- package/src/studio/samples/library.ts +0 -180
- package/src/studio/samples/live-data.test.ts +0 -99
- package/src/studio/samples/live-data.ts +0 -306
- package/src/studio/samples/projects.ts +0 -212
- package/src/studio/samples/realestate.ts +0 -196
- package/src/studio/samples/restaurant.ts +0 -189
- package/src/studio/samples/samples.test.ts +0 -304
- package/src/studio/samples/school.ts +0 -197
- package/src/studio/samples/seed-floor.test.ts +0 -25
- package/src/studio/samples/shared.ts +0 -237
- package/src/studio/samples/starter.ts +0 -251
- package/src/studio/samples/subscriptions.ts +0 -183
- package/src/studio/samples/support.ts +0 -216
- package/src/studio/scaffold-app.test.ts +0 -91
- package/src/studio/scaffold.test.ts +0 -192
- package/src/studio/scaffold.ts +0 -390
- package/src/studio/screen-suites.test.ts +0 -269
- package/src/studio/screen-suites.ts +0 -448
- package/src/studio/themes.ts +0 -62
- package/src/studio/ui-components-surface.test.ts +0 -185
- package/src/studio/ui-components.generated.ts +0 -7154
- package/src/studio/ui-components.ts +0 -742
- package/src/studio/verify.test.ts +0 -47
- package/src/studio/verify.ts +0 -79
- package/src/sveltekit/in-memory.test.ts +0 -104
- package/src/sveltekit/in-memory.ts +0 -129
- package/src/sveltekit/index.ts +0 -24
- package/src/sveltekit/query-plan.test.ts +0 -109
- package/src/sveltekit/query-plan.ts +0 -125
- package/src/sveltekit/sql-source.test.ts +0 -198
- package/src/sveltekit/sql-source.ts +0 -171
- package/src/sveltekit/sql.test.ts +0 -80
- package/src/sveltekit/sql.ts +0 -131
- package/src/sveltekit/transport-scope.test.ts +0 -125
- package/src/sveltekit/transport.test.ts +0 -201
- package/src/sveltekit/transport.ts +0 -373
- package/src/upgrade-prompt.test.ts +0 -71
- package/src/upgrade-prompt.ts +0 -162
- package/src/version.test.ts +0 -17
- package/src/watermark.test.ts +0 -97
- package/src/watermark.ts +0 -156
- /package/{src → dist}/SvAlertRuleEditor.svelte +0 -0
- /package/{src → dist}/SvAlertsManager.svelte +0 -0
- /package/{src → dist}/SvAlertsPanel.svelte +0 -0
- /package/{src → dist}/SvAuthGate.svelte +0 -0
- /package/{src → dist}/SvBoard.svelte +0 -0
- /package/{src → dist}/SvExportMenu.svelte +0 -0
- /package/{src → dist}/SvExpressionEditor.svelte +0 -0
- /package/{src → dist}/SvFileInput.svelte +0 -0
- /package/{src → dist}/SvGridAlerts.svelte +0 -0
- /package/{src → dist}/SvGridBoard.svelte +0 -0
- /package/{src → dist}/SvGridEditPanel.svelte +0 -0
- /package/{src → dist}/SvGridMasterDetail.svelte +0 -0
- /package/{src → dist}/SvGridScheduler.svelte +0 -0
- /package/{src → dist}/SvImportDialog.svelte +0 -0
- /package/{src → dist}/SvLookupInput.svelte +0 -0
- /package/{src → dist}/SvPivotDesigner.svelte +0 -0
- /package/{src → dist}/SvRecordDetail.svelte +0 -0
- /package/{src → dist}/SvSchedule.svelte +0 -0
- /package/{src → dist}/SvSchemaChart.svelte +0 -0
- /package/{src → dist}/SvSchemaDashboard.svelte +0 -0
- /package/{src → dist}/pdfmake-shims.d.ts +0 -0
- /package/{src → dist}/smart.export.d.ts +0 -0
- /package/{src → dist}/smart.export.js +0 -0
- /package/{src → dist}/studio/HANDLERS-DESIGN.md +0 -0
- /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -0
- /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -0
- /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -0
- /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
|
-
})
|