@svgrid/enterprise 2.2.1 → 2.3.1
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/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { compile } from 'svelte/compiler'
|
|
3
3
|
import type { EntitySchema } from '../schema'
|
|
4
|
-
import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts,
|
|
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
5
|
import ts from 'typescript'
|
|
6
|
-
import { emitStudioProject, emitStudioAppBundle, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
6
|
+
import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
7
7
|
import { UI_COMPONENT_REGISTRY } from './ui-components'
|
|
8
8
|
|
|
9
9
|
/** Type-check a handler BODY against a generated ambient ctx `.d.ts` using the real
|
|
@@ -48,6 +48,340 @@ const orders: EntitySchema = {
|
|
|
48
48
|
],
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
describe('SSR-native screens (renderMode: ssr)', () => {
|
|
52
|
+
const ssrProject = (): StudioProject => {
|
|
53
|
+
const p = createProject([customers])
|
|
54
|
+
return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('updateScreen can switch a screen to SSR (the CLI / MCP path, not just the designer)', () => {
|
|
58
|
+
// The other tests in this block set renderMode by spreading the screen object.
|
|
59
|
+
// Going through updateScreen is what the CLI and the studio_update_screen MCP
|
|
60
|
+
// tool do - and its patch type has to allow renderMode for that to be possible.
|
|
61
|
+
const p0 = createProject([customers])
|
|
62
|
+
const sid = p0.screens[0]!.id
|
|
63
|
+
expect(emitStudioProject(p0).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
|
|
64
|
+
|
|
65
|
+
const p1 = updateScreen(p0, sid, { renderMode: 'ssr' })
|
|
66
|
+
expect(p1.screens[0]!.renderMode).toBe('ssr')
|
|
67
|
+
const server = emitStudioProject(p1).find((f) => f.path === 'src/routes/customers/+page.server.ts')
|
|
68
|
+
expect(server, 'updateScreen({ renderMode: "ssr" }) should reach the SSR emitter').toBeTruthy()
|
|
69
|
+
expect(server!.contents).toContain('export const load')
|
|
70
|
+
expect(server!.contents).toContain('export const actions')
|
|
71
|
+
|
|
72
|
+
// And back again - the SPA page returns, the server page goes away.
|
|
73
|
+
const p2 = updateScreen(p1, sid, { renderMode: 'spa' })
|
|
74
|
+
expect(emitStudioProject(p2).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
|
|
78
|
+
const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
|
|
79
|
+
expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
|
|
80
|
+
const c = server!.contents
|
|
81
|
+
expect(c).toMatch(/export const ssr = true/) // opts back into SSR over the SPA layout's ssr=false
|
|
82
|
+
expect(c).toMatch(/export const load: PageServerLoad/)
|
|
83
|
+
expect(c).toMatch(/export const actions: Actions/)
|
|
84
|
+
expect(c).toMatch(/create:\s*async/)
|
|
85
|
+
expect(c).toMatch(/update:\s*async/)
|
|
86
|
+
expect(c).toMatch(/delete:\s*async/)
|
|
87
|
+
expect(c).toMatch(/await validateAll\(customersSchema, values\)/) // server-side validation
|
|
88
|
+
expect(c).toMatch(/return fail\(422/)
|
|
89
|
+
expect(c).toMatch(/planFromSearchParams\(url/)
|
|
90
|
+
expect(c).toMatch(/await customersSource\.getRows\(plan\)/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('emits an SSR +page.svelte: server rows, URL-driven sort/page, form-action editing', () => {
|
|
94
|
+
const page = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
95
|
+
expect(page).toMatch(/import type \{ PageProps \} from '\.\/\$types'/)
|
|
96
|
+
expect(page).toMatch(/data=\{data\.rows\}/) // renders SSR'd rows
|
|
97
|
+
expect(page).toMatch(/externalSort/)
|
|
98
|
+
expect(page).toMatch(/initialSorting=\{data\.sort\}/) // header indicator matches the URL sort on hard reload
|
|
99
|
+
expect(page).toMatch(/externalPagination/)
|
|
100
|
+
expect(page).toMatch(/onSortingChange=/)
|
|
101
|
+
expect(page).toMatch(/use:enhance=\{onSubmit\}/) // progressive enhancement
|
|
102
|
+
expect(page).toMatch(/action=\{isCreate \? '\?\/create' : '\?\/update'\}/)
|
|
103
|
+
// It must NOT fall back to the client data-source controller.
|
|
104
|
+
expect(page).not.toMatch(/createServerDataSource/)
|
|
105
|
+
// Valid Svelte 5.
|
|
106
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('SSR grid honors filterable: URL-driven filter (q + f_<col>) via applyFilters; off when not filterable', () => {
|
|
110
|
+
const p0 = createProject([customers])
|
|
111
|
+
const withFilter: StudioProject = {
|
|
112
|
+
...p0,
|
|
113
|
+
screens: p0.screens.map((s) => ({
|
|
114
|
+
...s,
|
|
115
|
+
renderMode: 'ssr' as const,
|
|
116
|
+
blocks: s.blocks.map((b) => (b.config.kind === 'grid' ? { ...b, config: { ...b.config, filterable: true } } : b)),
|
|
117
|
+
})),
|
|
118
|
+
}
|
|
119
|
+
const page = emitStudioProject(withFilter).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
120
|
+
expect(page).toMatch(/externalFilter/)
|
|
121
|
+
expect(page).toMatch(/onFiltersChange=\{applyFilters\}/)
|
|
122
|
+
expect(page).toMatch(/function applyFilters/)
|
|
123
|
+
expect(page).toMatch(/sp\.set\('f_' \+ c\.id, c\.value\)/) // per-column f_ params
|
|
124
|
+
// The default grid (filterable: false) omits the whole filter surface.
|
|
125
|
+
const off = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
126
|
+
expect(off).not.toMatch(/externalFilter/)
|
|
127
|
+
expect(off).not.toMatch(/function applyFilters/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('emits the shared server query helper once', () => {
|
|
131
|
+
const files = emitStudioProject(ssrProject())
|
|
132
|
+
const helpers = files.filter((f) => f.path === 'src/lib/server/query.ts')
|
|
133
|
+
expect(helpers).toHaveLength(1)
|
|
134
|
+
expect(helpers[0]!.contents).toMatch(/export function planFromSearchParams/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('sql SSR screen: load/actions proxy to the connected /api route via createKitDataSource', () => {
|
|
138
|
+
const p0 = createProject([customers])
|
|
139
|
+
const p: StudioProject = {
|
|
140
|
+
...p0,
|
|
141
|
+
dataSource: 'sql',
|
|
142
|
+
dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' } },
|
|
143
|
+
screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
|
|
144
|
+
}
|
|
145
|
+
const files = emitStudioProject(p)
|
|
146
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
147
|
+
expect(server).toMatch(/import \{ validateAll, createKitDataSource \} from '@svgrid\/enterprise'/)
|
|
148
|
+
expect(server).toMatch(/createKitDataSource<Customers>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
|
|
149
|
+
expect(server).toMatch(/load: PageServerLoad = async \(\{ url, fetch \}\)/) // load gets event.fetch
|
|
150
|
+
expect(server).not.toMatch(/from '\$lib\/data'/) // no in-process source import for sql
|
|
151
|
+
// The connected /api route is still emitted - enforcement (validate/RBAC/triggers/audit) lives there.
|
|
152
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeTruthy()
|
|
153
|
+
// Page still compiles.
|
|
154
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
155
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('read-only SSR screens (dashboard/kpi): load-only server file + page renders from data.*', () => {
|
|
159
|
+
let p = createProject([customers])
|
|
160
|
+
const sid = p.screens[0]!.id
|
|
161
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
162
|
+
p = addBlock(p, sid, 'kpi')
|
|
163
|
+
p = addBlock(p, sid, 'chart')
|
|
164
|
+
const files = emitStudioProject(p)
|
|
165
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
166
|
+
expect(server).toMatch(/export const ssr = true/)
|
|
167
|
+
expect(server).toMatch(/export const load: PageServerLoad/)
|
|
168
|
+
expect(server).toMatch(/const rows = \(await customersSource\.getRows\(PAGE\)\)\.rows/)
|
|
169
|
+
expect(server).not.toMatch(/export const actions/) // read-only
|
|
170
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
171
|
+
expect(page).toMatch(/let \{ data \}: PageProps = \$props\(\)/)
|
|
172
|
+
expect(page).toMatch(/const allRows = \$derived\(data\.rows as Customers\[\]\)/)
|
|
173
|
+
expect(page).not.toMatch(/loadAll\(\)/)
|
|
174
|
+
expect(page).not.toMatch(/from '\$lib\/data'/) // all data comes from the server load
|
|
175
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('read-only SSR + master-detail: child collections load server-side too (sql via /api)', () => {
|
|
179
|
+
const p0 = createProject([customers, orders])
|
|
180
|
+
let p: StudioProject = {
|
|
181
|
+
...p0,
|
|
182
|
+
dataSource: 'sql',
|
|
183
|
+
dataSources: {
|
|
184
|
+
customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
|
|
185
|
+
orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
const sid = p.screens[0]!.id
|
|
189
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
190
|
+
p = addBlock(p, sid, 'master-detail')
|
|
191
|
+
const mdId = p.screens[0]!.blocks.at(-1)!.id
|
|
192
|
+
p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
|
|
193
|
+
const files = emitStudioProject(p)
|
|
194
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
195
|
+
expect(server).toMatch(/createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
|
|
196
|
+
expect(server).toMatch(/const md_orders_rows = \(await createKitDataSource.*'\/api\/orders'/)
|
|
197
|
+
expect(server).toMatch(/return \{ rows, md_orders_rows \}/)
|
|
198
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
199
|
+
expect(page).toMatch(/const md_orders_rows = \$derived\(data\.md_orders_rows as Orders\[\]\)/)
|
|
200
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('a board screen stays SPA even with renderMode ssr (client-only interaction)', () => {
|
|
204
|
+
let p = createProject([customers])
|
|
205
|
+
const sid = p.screens[0]!.id
|
|
206
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
207
|
+
p = addBlock(p, sid, 'board')
|
|
208
|
+
const files = emitStudioProject(p)
|
|
209
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
|
|
210
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents).toMatch(/loadAll\(\)/)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('sql SSR + relation field: prefetches options via the related /api route', () => {
|
|
214
|
+
const p0 = createProject([customers, orders])
|
|
215
|
+
const p: StudioProject = {
|
|
216
|
+
...p0,
|
|
217
|
+
dataSource: 'sql',
|
|
218
|
+
dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' }, orders: { kind: 'sql', dialect: 'postgres', table: 'orders' } },
|
|
219
|
+
screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)),
|
|
220
|
+
}
|
|
221
|
+
const server = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
|
|
222
|
+
expect(server).toMatch(/const customer_idOptions = \(await createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)\.getRows\(/)
|
|
223
|
+
expect(server).toMatch(/, customer_idOptions \}/)
|
|
224
|
+
expect(server).not.toMatch(/from '\$lib\/data'/) // sql uses the /api route, not the in-process source
|
|
225
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
226
|
+
expect(page).toMatch(/<select name='customer_id'/)
|
|
227
|
+
expect(page).toMatch(/#each data\.customer_idOptions as o/)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('memory SSR + RBAC: load/actions enforce authz inline (sql inherits from /api)', () => {
|
|
231
|
+
const p0 = createProject([customers])
|
|
232
|
+
const p: StudioProject = {
|
|
233
|
+
...p0,
|
|
234
|
+
access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: p0.screens.map((s) => s.id), actions: [] }] },
|
|
235
|
+
screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
|
|
236
|
+
}
|
|
237
|
+
const server = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
238
|
+
expect(server).toMatch(/import \{ fail, error \} from '@sveltejs\/kit'/)
|
|
239
|
+
expect(server).toMatch(/import \{ authorizeAction, getServerRole \} from '\$lib\/access'/)
|
|
240
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'read', SCREEN_IDS\)/)
|
|
241
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'create', SCREEN_IDS\)/)
|
|
242
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'delete', SCREEN_IDS\)/)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('memory SSR + relation field: prefetches options in load and renders a <select>', () => {
|
|
246
|
+
const p0 = createProject([customers, orders])
|
|
247
|
+
const p: StudioProject = { ...p0, screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)) }
|
|
248
|
+
const files = emitStudioProject(p)
|
|
249
|
+
const server = files.find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
|
|
250
|
+
expect(server).toMatch(/import \{ ordersSource, customersSource, nextId \} from '\$lib\/data'/)
|
|
251
|
+
expect(server).toMatch(/const customer_idOptions = \(await customersSource\.getRows\(/)
|
|
252
|
+
expect(server).toMatch(/label: String\(r\['name'\]/) // uses the relation's labelField
|
|
253
|
+
expect(server).toMatch(/, customer_idOptions \}/) // options returned from load
|
|
254
|
+
const page = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
255
|
+
expect(page).toMatch(/<select name='customer_id'/)
|
|
256
|
+
expect(page).toMatch(/#each data\.customer_idOptions as o/)
|
|
257
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('leaves spa screens on the client-controller path', () => {
|
|
261
|
+
const p = createProject([customers]) // default renderMode is spa
|
|
262
|
+
const files = emitStudioProject(p)
|
|
263
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
|
|
264
|
+
expect(files.find((f) => f.path === 'src/lib/server/query.ts')).toBeUndefined()
|
|
265
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
266
|
+
expect(page).toMatch(/createServerDataSource/)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('emitStudioFragment (drop into an existing app)', () => {
|
|
271
|
+
it('emits app content minus the shell, plus app.css + FRAGMENT.md', () => {
|
|
272
|
+
const files = emitStudioFragment(createProject([customers]))
|
|
273
|
+
const paths = files.map((f) => f.path)
|
|
274
|
+
// No whole-app scaffolding.
|
|
275
|
+
expect(paths).not.toContain('package.json')
|
|
276
|
+
expect(paths).not.toContain('svelte.config.js')
|
|
277
|
+
expect(paths).not.toContain('src/routes/+layout.svelte') // the nav shell
|
|
278
|
+
expect(paths).not.toContain('src/routes/+page.svelte') // the home redirect
|
|
279
|
+
// The screens + lib + the lifted styles + the readme ARE present.
|
|
280
|
+
expect(paths).toContain('src/routes/customers/+page.svelte')
|
|
281
|
+
expect(paths).toContain('src/lib/schemas.ts')
|
|
282
|
+
expect(paths).toContain('src/lib/data.ts')
|
|
283
|
+
expect(paths).toContain('src/app.css')
|
|
284
|
+
expect(paths).toContain('FRAGMENT.md')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('FRAGMENT.md reports the runtime deps + host requirements', () => {
|
|
288
|
+
let p = createProject([customers])
|
|
289
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
290
|
+
const md = emitStudioFragment(p).find((f) => f.path === 'FRAGMENT.md')!.contents
|
|
291
|
+
expect(md).toMatch(/npm install .*\bpg\b/) // the sql driver the fragment imports
|
|
292
|
+
expect(md).toMatch(/DATABASE_URL/) // env the host must set
|
|
293
|
+
expect(md).toMatch(/Import `src\/app\.css`/)
|
|
294
|
+
expect(md).toMatch(/nav shell.*NOT here/s)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('db/schema.sql + db/seed.sql + drizzle db:seed (real-DB last mile)', () => {
|
|
299
|
+
const sqlProject = (dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'turso'): StudioProject => {
|
|
300
|
+
let p = createProject([customers, orders])
|
|
301
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
|
|
302
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect })
|
|
303
|
+
return p
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
it('emits db/schema.sql + db/seed.sql for SQL entities without the Drizzle layer', () => {
|
|
307
|
+
const files = emitStudioProject(sqlProject('postgres'))
|
|
308
|
+
const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
|
|
309
|
+
expect(ddl).toMatch(/CREATE TABLE IF NOT EXISTS "customers" \(/)
|
|
310
|
+
expect(ddl).toMatch(/"id" text PRIMARY KEY/)
|
|
311
|
+
expect(ddl).toMatch(/"mrr" double precision/)
|
|
312
|
+
// Relation display fields are runtime labels, not columns.
|
|
313
|
+
const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
|
|
314
|
+
expect(seedSql).toMatch(/INSERT INTO "customers" \(/)
|
|
315
|
+
expect(seedSql).toMatch(/INSERT INTO "orders" \(/)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('mssql gets a guarded CREATE TABLE (no Drizzle possible) with N-literals', () => {
|
|
319
|
+
const files = emitStudioProject(sqlProject('mssql'))
|
|
320
|
+
const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
|
|
321
|
+
expect(ddl).toMatch(/IF OBJECT_ID\(N'customers', N'U'\) IS NULL/)
|
|
322
|
+
expect(ddl).toMatch(/"mrr" float/)
|
|
323
|
+
const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
|
|
324
|
+
expect(seedSql).toMatch(/N'/)
|
|
325
|
+
// Even WITH the drizzle toggle, mssql still ships plain SQL (dzDialect is null).
|
|
326
|
+
const withLayer = emitStudioProject(setDataLayer(sqlProject('mssql'), true))
|
|
327
|
+
expect(withLayer.some((f) => f.path === 'db/schema.sql')).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('mysql uses backticks; sqlite/turso use sqlite types', () => {
|
|
331
|
+
const my = emitStudioProject(sqlProject('mysql')).find((f) => f.path === 'db/schema.sql')!.contents
|
|
332
|
+
expect(my).toMatch(/CREATE TABLE IF NOT EXISTS `customers` \(/)
|
|
333
|
+
expect(my).toMatch(/`mrr` double/)
|
|
334
|
+
const tu = emitStudioProject(sqlProject('turso')).find((f) => f.path === 'db/schema.sql')!.contents
|
|
335
|
+
expect(tu).toMatch(/"mrr" real/)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('the Drizzle layer replaces plain DDL with migrations + a db:seed script', () => {
|
|
339
|
+
const p = setDataLayer(sqlProject('postgres'), true)
|
|
340
|
+
const files = emitStudioAppBundle(p)
|
|
341
|
+
expect(files.some((f) => f.path === 'db/schema.sql')).toBe(false) // migrations own DDL
|
|
342
|
+
const seedTs = files.find((f) => f.path === 'db/seed.ts')!.contents
|
|
343
|
+
expect(seedTs).toMatch(/studio:db-seed/)
|
|
344
|
+
expect(seedTs).toMatch(/process\.env\.DATABASE_URL/) // own client - no \$env import
|
|
345
|
+
expect(seedTs).not.toMatch(/\$env\/dynamic\/private/)
|
|
346
|
+
expect(seedTs).toMatch(/db\.insert\(schema\.customers\)/)
|
|
347
|
+
expect(seedTs).toMatch(/existing\.length === 0/) // idempotent
|
|
348
|
+
const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
|
|
349
|
+
expect(pkg.scripts['db:seed']).toBe('tsx db/seed.ts')
|
|
350
|
+
expect(pkg.devDependencies.tsx).toBeTruthy()
|
|
351
|
+
const deploy = files.find((f) => f.path === 'DEPLOY.md')!.contents
|
|
352
|
+
expect(deploy).toMatch(/npm run db:seed/)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('DEPLOY.md points at db/schema.sql on the non-Drizzle path', () => {
|
|
356
|
+
const deploy = emitStudioAppBundle(sqlProject('postgres')).find((f) => f.path === 'DEPLOY.md')!.contents
|
|
357
|
+
expect(deploy).toMatch(/db\/schema\.sql/)
|
|
358
|
+
expect(deploy).toMatch(/VITE_SVPRO_KEY/)
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
describe('secure-by-default auth', () => {
|
|
363
|
+
const authApp = () => emitStudioAppBundle(setAuth(createProject([customers]), { enabled: true, protect: true }))
|
|
364
|
+
|
|
365
|
+
it('ships a git-ignored .env with a real random SESSION_SECRET (not the hardcoded fallback)', () => {
|
|
366
|
+
const env = authApp().find((f) => f.path === '.env')
|
|
367
|
+
expect(env, 'an auth app should ship a .env with a real secret').toBeTruthy()
|
|
368
|
+
expect(env!.contents).toMatch(/SESSION_SECRET=[0-9a-f]{64}/)
|
|
369
|
+
expect(env!.contents).not.toMatch(/change-me-to-a-long-random-string/)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('hashes demo users - no plaintext store or plaintext login compare', () => {
|
|
373
|
+
const files = authApp()
|
|
374
|
+
const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
|
|
375
|
+
expect(users).toMatch(/passwordHash: await hashPassword/)
|
|
376
|
+
// The stored user type carries only the hash (the SEED keeps plaintext defaults,
|
|
377
|
+
// which are hashed at module load).
|
|
378
|
+
expect(users).toMatch(/AppUser = \{ email: string; name: string; role: string; passwordHash: string \}/)
|
|
379
|
+
const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
|
|
380
|
+
expect(login).toMatch(/await verifyPassword\(password, user\.passwordHash\)/)
|
|
381
|
+
expect(login).not.toMatch(/user\.password !== password/)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
51
385
|
describe('emitStudioProject (per-block screens)', () => {
|
|
52
386
|
const project = createProject([customers, orders])
|
|
53
387
|
const files = emitStudioProject(project)
|
|
@@ -408,7 +742,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
408
742
|
expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
|
|
409
743
|
// The shell wires the session in: login bypass, real user, sign-out, role seed.
|
|
410
744
|
const layout = get('src/routes/+layout.svelte')!
|
|
411
|
-
expect(layout).toContain('["/login"].includes(
|
|
745
|
+
expect(layout).toContain('["/login"].includes(page.url.pathname)') // login renders bare (no shell)
|
|
412
746
|
expect(layout).toContain('action="/logout"')
|
|
413
747
|
expect(layout).toContain('currentRole.set(data.role')
|
|
414
748
|
expect(layout).toContain('data?.user?.email')
|
|
@@ -473,7 +807,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
473
807
|
|
|
474
808
|
it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
|
|
475
809
|
// Off by default.
|
|
476
|
-
|
|
810
|
+
const sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
477
811
|
expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
478
812
|
// Toggle on but no SQL entity (memory source) -> nothing.
|
|
479
813
|
expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
|
|
@@ -635,6 +969,38 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
635
969
|
expect(users).not.toContain('viewer@example.com')
|
|
636
970
|
})
|
|
637
971
|
|
|
972
|
+
it('Supabase Auth: gates the app via SvAuthGate over the shared client, not the builtin session', () => {
|
|
973
|
+
let p = createProject([customers])
|
|
974
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
975
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
976
|
+
const files = emitStudioProject(p)
|
|
977
|
+
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
|
|
978
|
+
expect(layout).toContain('<SvAuthGate')
|
|
979
|
+
expect(layout).toContain("import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'")
|
|
980
|
+
expect(layout).toContain("import { supabaseClient } from '$lib/connections'")
|
|
981
|
+
// Shared client emitted; NO builtin cookie-session scaffold.
|
|
982
|
+
expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
|
|
983
|
+
expect(files.find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
|
|
984
|
+
expect(files.find((f) => f.path === 'src/lib/server/users.ts')).toBeUndefined()
|
|
985
|
+
expect(files.find((f) => f.path === 'src/routes/login/+page.svelte')).toBeUndefined()
|
|
986
|
+
// No cosmetic fake "Sign out" in the account menu - SvAuthGate's own bar owns it.
|
|
987
|
+
expect(layout).not.toMatch(/menuOpen = false\)}>Sign out/)
|
|
988
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
989
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
990
|
+
}
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
it('Supabase Auth emits the shared client even with no Supabase-bound entity', () => {
|
|
994
|
+
let p = createProject([customers]) // stays in-memory
|
|
995
|
+
p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anon' } }
|
|
996
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
997
|
+
const files = emitStudioProject(p)
|
|
998
|
+
expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
|
|
999
|
+
expect(files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents).toContain('<SvAuthGate')
|
|
1000
|
+
// data.ts does NOT import the client (no Supabase data entity uses it).
|
|
1001
|
+
expect(files.find((f) => f.path === 'src/lib/data.ts')!.contents).not.toContain("from './connections'")
|
|
1002
|
+
})
|
|
1003
|
+
|
|
638
1004
|
it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
|
|
639
1005
|
let p = createProject([customers, orders])
|
|
640
1006
|
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
@@ -665,6 +1031,191 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
665
1031
|
expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
|
|
666
1032
|
})
|
|
667
1033
|
|
|
1034
|
+
it('every registered toolbox component generates code that compiles', () => {
|
|
1035
|
+
// The toolbox is registry-driven, so a new registration reaches codegen with
|
|
1036
|
+
// no per-component work - which is exactly why it needs a blanket check that
|
|
1037
|
+
// each one actually emits a valid import + usage.
|
|
1038
|
+
for (const spec of UI_COMPONENT_REGISTRY) {
|
|
1039
|
+
let p = createProject([customers])
|
|
1040
|
+
const sid = p.screens[0]!.id
|
|
1041
|
+
const defaults: Record<string, unknown> = {}
|
|
1042
|
+
for (const pr of spec.props) if (pr.default != null) defaults[pr.key] = pr.default
|
|
1043
|
+
if (spec.hasContent) defaults._content = spec.contentDefault ?? spec.label
|
|
1044
|
+
p = addComponentBlock(p, sid, spec.key, defaults)
|
|
1045
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
|
|
1046
|
+
expect(page.contents, `${spec.key} did not import ${spec.importName}`).toContain(spec.importName)
|
|
1047
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' }), `${spec.key} emitted invalid Svelte`).not.toThrow()
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
describe('multi-tenancy', () => {
|
|
1052
|
+
/** Tenancy needs auth (to know the tenant) + the typed layer (for the column). */
|
|
1053
|
+
const tenantProject = () => {
|
|
1054
|
+
let p = createProject([customers, orders])
|
|
1055
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1056
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1057
|
+
p = setDataLayer(p, true)
|
|
1058
|
+
p = setAuth(p, { enabled: true })
|
|
1059
|
+
return setTenancy(p, { enabled: true })
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
it('scopes every SQL route server-side', () => {
|
|
1063
|
+
const files = emitStudioProject(tenantProject())
|
|
1064
|
+
for (const entity of ['customers', 'orders']) {
|
|
1065
|
+
const route = files.find((f) => f.path === `src/routes/api/${entity}/+server.ts`)!.contents
|
|
1066
|
+
expect(route, entity).toContain("import { requireTenant } from '$lib/server/tenant'")
|
|
1067
|
+
expect(route, entity).toContain("scope: ({ event }) => ({ field: \"tenantId\", value: requireTenant(event) })")
|
|
1068
|
+
}
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
it('resolves the tenant from the session and fails closed', () => {
|
|
1072
|
+
const files = emitStudioProject(tenantProject())
|
|
1073
|
+
const tenant = files.find((f) => f.path === 'src/lib/server/tenant.ts')!.contents
|
|
1074
|
+
// requireTenant THROWS rather than returning null - the transport turns that
|
|
1075
|
+
// into a 403. Returning null would run the query unscoped.
|
|
1076
|
+
expect(tenant).toContain('export function requireTenant')
|
|
1077
|
+
expect(tenant).toContain('throw new Error')
|
|
1078
|
+
// The tenant comes off the session in hooks, never from the client.
|
|
1079
|
+
const hooks = files.find((f) => f.path === 'src/hooks.server.ts')!.contents
|
|
1080
|
+
expect(hooks).toContain('event.locals.tenantId')
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
it('adds the scoping column to every scoped table + the user store', () => {
|
|
1084
|
+
const schema = emitStudioProject(tenantProject()).find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
1085
|
+
// notNull: a row with no tenant belongs to nobody and no scoped read finds it.
|
|
1086
|
+
expect(schema).toContain('"tenantId": text("tenant_id").notNull()')
|
|
1087
|
+
// The user store carries it too - that is where the session's tenant comes from.
|
|
1088
|
+
expect(schema.slice(schema.indexOf('authUsers'))).toContain('"tenantId"')
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
it('leaves shared entities global (no column, no scope)', () => {
|
|
1092
|
+
let p = tenantProject()
|
|
1093
|
+
p = setTenancy(p, { enabled: true, sharedEntities: ['orders'] })
|
|
1094
|
+
const files = emitStudioProject(p)
|
|
1095
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('scope:')
|
|
1096
|
+
expect(files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents).not.toContain('scope:')
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it('honours a custom scoping column', () => {
|
|
1100
|
+
let p = tenantProject()
|
|
1101
|
+
p = setTenancy(p, { enabled: true, field: 'orgId' })
|
|
1102
|
+
const files = emitStudioProject(p)
|
|
1103
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('field: "orgId"')
|
|
1104
|
+
expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('"orgId": text("org_id").notNull()')
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('degrades to OFF without auth or the typed data layer, rather than half-enforcing', () => {
|
|
1108
|
+
// No auth: nothing can tell us who the tenant is.
|
|
1109
|
+
let noAuth = createProject([customers])
|
|
1110
|
+
noAuth = setEntityDataSource(noAuth, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1111
|
+
noAuth = setDataLayer(noAuth, true)
|
|
1112
|
+
noAuth = setTenancy(noAuth, { enabled: true })
|
|
1113
|
+
expect(emitStudioProject(noAuth).find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1114
|
+
|
|
1115
|
+
// No typed layer: the scoping column would not exist in any schema.
|
|
1116
|
+
let noLayer = createProject([customers])
|
|
1117
|
+
noLayer = setEntityDataSource(noLayer, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1118
|
+
noLayer = setAuth(noLayer, { enabled: true })
|
|
1119
|
+
noLayer = setTenancy(noLayer, { enabled: true })
|
|
1120
|
+
const files = emitStudioProject(noLayer)
|
|
1121
|
+
expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1122
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).not.toContain('scope:')
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('emits nothing tenancy-related when it is off', () => {
|
|
1126
|
+
const files = emitStudioProject(createProject([customers]))
|
|
1127
|
+
expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1128
|
+
})
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
it('Scheduled jobs emit a guarded /api/cron + handler registry', () => {
|
|
1132
|
+
let p = createProject([customers, orders])
|
|
1133
|
+
p = setJob(p, 'nightly-digest', { name: 'Nightly digest', cron: '0 6 * * *', kind: 'code', code: "console.log('tick')" })
|
|
1134
|
+
const files = emitStudioProject(p)
|
|
1135
|
+
|
|
1136
|
+
const route = files.find((f) => f.path === 'src/routes/api/cron/+server.ts')!.contents
|
|
1137
|
+
// Secret-guarded, and refuses to run when no secret is configured - an
|
|
1138
|
+
// unguarded "do work" URL is the failure mode worth preventing.
|
|
1139
|
+
expect(route).toContain('env.CRON_SECRET')
|
|
1140
|
+
expect(route).toContain('if (!secret) return false')
|
|
1141
|
+
expect(route).toContain("throw error(401")
|
|
1142
|
+
// One failing job must not abort the rest of the run.
|
|
1143
|
+
expect(route).toContain('try { await fn()')
|
|
1144
|
+
|
|
1145
|
+
const jobs = files.find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1146
|
+
expect(jobs).toContain('"nightly-digest"')
|
|
1147
|
+
expect(jobs).toContain("console.log('tick')")
|
|
1148
|
+
expect(jobs).toContain('export const enabledJobs: string[] = ["nightly-digest"]')
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
it('a disabled job keeps its handler but drops out of the scheduled run', () => {
|
|
1152
|
+
let p = createProject([customers])
|
|
1153
|
+
p = setJob(p, 'a', { name: 'A', cron: '0 1 * * *', kind: 'code', code: 'void 0' })
|
|
1154
|
+
p = setJob(p, 'b', { name: 'B', cron: '0 2 * * *', kind: 'code', code: 'void 0', enabled: false })
|
|
1155
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1156
|
+
expect(jobs).toContain('"a"')
|
|
1157
|
+
expect(jobs).toContain('"b"') // still callable via ?job=b
|
|
1158
|
+
expect(jobs).toContain('export const enabledJobs: string[] = ["a"]')
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
it('an email job without the email layer emits a warning slot, not a broken import', () => {
|
|
1162
|
+
let p = createProject([customers])
|
|
1163
|
+
p = setJob(p, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'email', entity: 'customers', to: 'ops@example.com' })
|
|
1164
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1165
|
+
expect(jobs).not.toContain("from '$lib/server/email'")
|
|
1166
|
+
expect(jobs).toContain('email is not enabled on this project')
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('an email job with the email layer on sends a summary of its entity', () => {
|
|
1170
|
+
let p = createProject([customers, orders])
|
|
1171
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1172
|
+
p = setDataLayer(p, true)
|
|
1173
|
+
p = setAuth(p, { enabled: true, email: true })
|
|
1174
|
+
p = setJob(p, 'digest', { name: 'Daily orders', cron: '0 6 * * *', kind: 'email', entity: 'orders', to: 'ops@example.com', subject: 'Orders today' })
|
|
1175
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1176
|
+
expect(jobs).toContain("import { sendEmail } from '$lib/server/email'")
|
|
1177
|
+
expect(jobs).toContain("sendEmail('ops@example.com', 'Orders today'")
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('no jobs emits no cron route at all', () => {
|
|
1181
|
+
const files = emitStudioProject(createProject([customers]))
|
|
1182
|
+
expect(files.find((f) => f.path === 'src/routes/api/cron/+server.ts')).toBeUndefined()
|
|
1183
|
+
expect(files.find((f) => f.path === 'src/lib/server/jobs.ts')).toBeUndefined()
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('Audit persists to an audit_log table when the typed data layer is on', () => {
|
|
1187
|
+
let p = createProject([customers, orders])
|
|
1188
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1189
|
+
p = { ...p, audit: true, dataLayer: 'drizzle' }
|
|
1190
|
+
const files = emitStudioProject(p)
|
|
1191
|
+
|
|
1192
|
+
// The trail gets a real table in the same schema, so one migration covers it.
|
|
1193
|
+
const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
1194
|
+
expect(schema).toContain('export const auditLog =')
|
|
1195
|
+
expect(schema).toContain('"audit_log"')
|
|
1196
|
+
// before / after snapshots - the in-memory store only kept field names.
|
|
1197
|
+
expect(schema).toContain('"before"')
|
|
1198
|
+
expect(schema).toContain('"after"')
|
|
1199
|
+
|
|
1200
|
+
const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
|
|
1201
|
+
expect(audit).toContain("from '$lib/server/db'")
|
|
1202
|
+
expect(audit).toContain('db.insert(auditLog)')
|
|
1203
|
+
// Paged in the database, and read-only (append-only through recordAudit).
|
|
1204
|
+
expect(audit).toContain('async getRows(')
|
|
1205
|
+
expect(audit).toContain('.limit(limit).offset(start)')
|
|
1206
|
+
expect(audit).not.toContain('createInMemoryDataSource')
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
it('Audit stays in-memory (and says so) without the typed data layer', () => {
|
|
1210
|
+
let p = createProject([customers, orders])
|
|
1211
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1212
|
+
p = { ...p, audit: true }
|
|
1213
|
+
const files = emitStudioProject(p)
|
|
1214
|
+
const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
|
|
1215
|
+
expect(audit).toContain('createInMemoryDataSource')
|
|
1216
|
+
expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
1217
|
+
})
|
|
1218
|
+
|
|
668
1219
|
it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
|
|
669
1220
|
let p = createProject([customers, orders])
|
|
670
1221
|
const cid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
@@ -887,7 +1438,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
887
1438
|
expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
|
|
888
1439
|
|
|
889
1440
|
const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
890
|
-
expect(ord).toContain("import { page } from '$app/
|
|
1441
|
+
expect(ord).toContain("import { page } from '$app/state'") // drill TARGET reads URL params
|
|
891
1442
|
expect(ord).toContain('sp.get(_f)')
|
|
892
1443
|
|
|
893
1444
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
@@ -905,7 +1456,15 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
905
1456
|
expect(cat).toContain('export function localizeCols')
|
|
906
1457
|
expect(cat).toContain('"col.customers.name"') // seeded from the schema
|
|
907
1458
|
expect(cat).toContain('"nav.customers"')
|
|
908
|
-
|
|
1459
|
+
// EVERY locale is seeded with the same keys, not just the default - an empty
|
|
1460
|
+
// `{}` made the translator hunt for key names in the codegen.
|
|
1461
|
+
expect(cat).not.toContain('"es": {}')
|
|
1462
|
+
expect(cat).toMatch(/"es": \{[\s\S]*"col\.customers\.name"/)
|
|
1463
|
+
expect(cat).toContain('// TODO: translate')
|
|
1464
|
+
// Both locales carry the full key set.
|
|
1465
|
+
const keyCount = (block: string) => (block.match(/"col\.customers\./g) ?? []).length
|
|
1466
|
+
const [, enBlock = '', esBlock = ''] = cat.split(/"(?:en|es)": /)
|
|
1467
|
+
expect(keyCount(enBlock)).toBe(keyCount(esBlock))
|
|
909
1468
|
|
|
910
1469
|
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
911
1470
|
expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
|
|
@@ -1034,6 +1593,49 @@ describe('Custom actions', () => {
|
|
|
1034
1593
|
})
|
|
1035
1594
|
})
|
|
1036
1595
|
|
|
1596
|
+
describe('form/dialog layout depth', () => {
|
|
1597
|
+
const gridScreen = (extra: Partial<GridConfig>) => {
|
|
1598
|
+
let p = createProject([customers])
|
|
1599
|
+
const sid = p.screens[0]!.id
|
|
1600
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1601
|
+
p = updateBlock(p, sid, gid, { config: { editing: 'form', ...extra } as Partial<GridConfig> })
|
|
1602
|
+
return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const panelLine = (page: string) => page.split('\n').find((l) => l.includes('<SvGridEditPanel schema='))!
|
|
1606
|
+
|
|
1607
|
+
it('emits nothing extra for a default form', () => {
|
|
1608
|
+
const line = panelLine(gridScreen({}))
|
|
1609
|
+
expect(line).toMatch(/<SvGridEditPanel schema=\{customersSchema\} row=\{editing\}/)
|
|
1610
|
+
expect(line).not.toMatch(/columns=\{/)
|
|
1611
|
+
expect(line).not.toMatch(/sections=\{/)
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
it('passes columns / field selection+order / sections / title / size to the panel', () => {
|
|
1615
|
+
const page = gridScreen({
|
|
1616
|
+
formColumns: 3,
|
|
1617
|
+
formFields: ['name', 'email'],
|
|
1618
|
+
formSections: [{ title: 'Contact', fields: ['name', 'email'] }],
|
|
1619
|
+
formTitle: 'Edit customer',
|
|
1620
|
+
formSize: 'lg',
|
|
1621
|
+
})
|
|
1622
|
+
const line = panelLine(page)
|
|
1623
|
+
expect(line).toMatch(/columns=\{3\}/)
|
|
1624
|
+
expect(line).toMatch(/formFields=\{\["name","email"\]\}/)
|
|
1625
|
+
expect(line).toMatch(/sections=\{\[\{"title":"Contact","fields":\["name","email"\]\}\]\}/)
|
|
1626
|
+
expect(line).toMatch(/title=\{'Edit customer'\}/)
|
|
1627
|
+
expect(line).toMatch(/formSize='lg'/)
|
|
1628
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
1629
|
+
})
|
|
1630
|
+
|
|
1631
|
+
it('omits default-valued knobs (columns 2, size md)', () => {
|
|
1632
|
+
const line = panelLine(gridScreen({ formColumns: 2, formSize: 'md', formFields: ['name'] }))
|
|
1633
|
+
expect(line).not.toMatch(/columns=\{/)
|
|
1634
|
+
expect(line).not.toMatch(/formSize=/)
|
|
1635
|
+
expect(line).toMatch(/formFields=\{\["name"\]\}/)
|
|
1636
|
+
})
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1037
1639
|
describe('Component blocks', () => {
|
|
1038
1640
|
it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
|
|
1039
1641
|
let p = createProject([customers])
|
|
@@ -1044,7 +1646,10 @@ describe('Component blocks', () => {
|
|
|
1044
1646
|
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1045
1647
|
const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
|
|
1046
1648
|
expect(importLine).toContain('SvButton') // merged into the screen's one @svgrid/grid import
|
|
1047
|
-
|
|
1649
|
+
// Full extracted surface: curated attrs first, then the rest of the component's
|
|
1650
|
+
// real props (e.g. type={'button'}); content children last.
|
|
1651
|
+
expect(page).toContain("<SvButton variant={'primary'} size={'md'} block")
|
|
1652
|
+
expect(page).toContain(">{'Click me'}</SvButton>")
|
|
1048
1653
|
|
|
1049
1654
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1050
1655
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
@@ -1060,8 +1665,10 @@ describe('Component blocks', () => {
|
|
|
1060
1665
|
|
|
1061
1666
|
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1062
1667
|
expect(page).toContain("import { SvBadge, SvButton } from '@svgrid/grid'") // sorted, deduped
|
|
1063
|
-
expect(page).toContain("<SvButton variant={'secondary'} size={'md'}
|
|
1064
|
-
expect(page).toContain("
|
|
1668
|
+
expect(page).toContain("<SvButton variant={'secondary'} size={'md'}")
|
|
1669
|
+
expect(page).toContain(">{'Click me'}</SvButton>")
|
|
1670
|
+
expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill")
|
|
1671
|
+
expect(page).toContain(">{'Badge'}</SvBadge>")
|
|
1065
1672
|
expect(page).not.toContain('Add your own content here')
|
|
1066
1673
|
|
|
1067
1674
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
@@ -1180,6 +1787,41 @@ describe('data-source codegen', () => {
|
|
|
1180
1787
|
expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
|
|
1181
1788
|
})
|
|
1182
1789
|
|
|
1790
|
+
it('REST adapter (dummyjson): emits real server paging via the adapter + imports it', () => {
|
|
1791
|
+
let p = createProject([customers])
|
|
1792
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1793
|
+
kind: 'rest', baseUrl: 'https://dummyjson.com', path: 'users', method: 'GET', params: [],
|
|
1794
|
+
adapter: { kind: 'dummyjson', rowsKey: 'users' },
|
|
1795
|
+
})
|
|
1796
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1797
|
+
expect(dataTs).toContain('dummyJsonAdapter') // imported from @svgrid/enterprise
|
|
1798
|
+
expect(dataTs).toMatch(/\.\.\.dummyJsonAdapter<\w+>\('users'\)/)
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
it('REST offsetLimit adapter emits only the configured params', () => {
|
|
1802
|
+
let p = createProject([customers])
|
|
1803
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1804
|
+
kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
|
|
1805
|
+
adapter: { kind: 'offsetLimit', offsetParam: '_start', limitParam: '_limit', sortByParam: 'sort' },
|
|
1806
|
+
})
|
|
1807
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1808
|
+
expect(dataTs).toContain("offsetParam: '_start'")
|
|
1809
|
+
expect(dataTs).toContain("limitParam: '_limit'")
|
|
1810
|
+
expect(dataTs).toContain("sortByParam: 'sort'")
|
|
1811
|
+
expect(dataTs).not.toContain('orderParam') // unset fields are omitted
|
|
1812
|
+
})
|
|
1813
|
+
|
|
1814
|
+
it('a REST adapter supersedes the manual rowsPath/totalPath parse', () => {
|
|
1815
|
+
let p = createProject([customers])
|
|
1816
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1817
|
+
kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
|
|
1818
|
+
adapter: { kind: 'jsonServer' }, rowsPath: 'data', totalPath: 'total',
|
|
1819
|
+
})
|
|
1820
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1821
|
+
expect(dataTs).toContain('jsonServerAdapter')
|
|
1822
|
+
expect(dataTs).not.toContain('parse: (body: any)') // adapter provides parse instead
|
|
1823
|
+
})
|
|
1824
|
+
|
|
1183
1825
|
it('substitutes static path params into the URL', () => {
|
|
1184
1826
|
let p = createProject([customers])
|
|
1185
1827
|
p = setEntityDataSource(p, 'customers', {
|
|
@@ -1245,20 +1887,30 @@ describe('data-source codegen', () => {
|
|
|
1245
1887
|
expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
|
|
1246
1888
|
})
|
|
1247
1889
|
|
|
1248
|
-
it('emits a
|
|
1890
|
+
it('emits a Supabase client that reads PUBLIC_SUPABASE_* env vars (never inlines the key)', () => {
|
|
1249
1891
|
let p = createProject([customers])
|
|
1250
1892
|
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
|
|
1251
1893
|
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1252
1894
|
expect(conn).toContain("import { createClient } from '@supabase/supabase-js'")
|
|
1253
|
-
expect(conn).toContain("
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1895
|
+
expect(conn).toContain("import { env } from '$env/dynamic/public'")
|
|
1896
|
+
expect(conn).toContain('createClient(')
|
|
1897
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
|
|
1898
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_ANON_KEY')
|
|
1899
|
+
// The anon key is NEVER baked into source (it goes to .env); the designer values
|
|
1900
|
+
// appear only as a paste-into-.env comment.
|
|
1901
|
+
expect(conn).not.toContain("createClient('https://x.supabase.co'")
|
|
1902
|
+
// .env.example lists the public Supabase keys.
|
|
1903
|
+
const envEx = emitStudioAppBundle(p).find((f) => f.path === '.env.example')!.contents
|
|
1904
|
+
expect(envEx).toContain('PUBLIC_SUPABASE_URL=')
|
|
1905
|
+
expect(envEx).toContain('PUBLIC_SUPABASE_ANON_KEY=')
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
it('still emits an env-var Supabase client without designer url/key', () => {
|
|
1257
1909
|
let p = createProject([customers])
|
|
1258
1910
|
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1259
1911
|
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1260
|
-
expect(conn).toContain('
|
|
1261
|
-
expect(conn).not.
|
|
1912
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
|
|
1913
|
+
expect(conn).not.toContain('null as unknown as SupabaseClientLike') // no stub - reads env
|
|
1262
1914
|
})
|
|
1263
1915
|
|
|
1264
1916
|
it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
|
|
@@ -1269,6 +1921,50 @@ describe('data-source codegen', () => {
|
|
|
1269
1921
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1270
1922
|
}
|
|
1271
1923
|
})
|
|
1924
|
+
|
|
1925
|
+
it('Supabase realtime: subscribes the grid page to live changes and refreshes on change', () => {
|
|
1926
|
+
let p = createProject([customers])
|
|
1927
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', realtime: true })
|
|
1928
|
+
const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
|
|
1929
|
+
expect(page).toContain('createSupabaseRealtime')
|
|
1930
|
+
expect(page).toContain("import { supabaseClient } from '$lib/connections'")
|
|
1931
|
+
expect(page).toContain("table: 'customers'")
|
|
1932
|
+
expect(page).toContain('onChange: () => controller.refresh()')
|
|
1933
|
+
// Cleanup unsubscribes the channel alongside disposing the controller.
|
|
1934
|
+
expect(page).toContain('__rt.unsubscribe()')
|
|
1935
|
+
expect(page).toContain('controller.dispose()')
|
|
1936
|
+
// Compiles.
|
|
1937
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
1938
|
+
})
|
|
1939
|
+
|
|
1940
|
+
it('Supabase without realtime does NOT emit a subscription', () => {
|
|
1941
|
+
let p = createProject([customers])
|
|
1942
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1943
|
+
const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
|
|
1944
|
+
expect(page).not.toContain('createSupabaseRealtime')
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1947
|
+
it('project-level Supabase connection feeds the .env paste hint (entity carries only the table)', () => {
|
|
1948
|
+
let p = createProject([customers])
|
|
1949
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1950
|
+
p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anonKEY' } }
|
|
1951
|
+
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1952
|
+
expect(conn).toContain('PUBLIC_SUPABASE_URL=https://proj.supabase.co')
|
|
1953
|
+
expect(conn).toContain('PUBLIC_SUPABASE_ANON_KEY=anonKEY')
|
|
1954
|
+
// The client still reads env (project-level values only seed the paste comment).
|
|
1955
|
+
expect(conn).not.toContain("createClient('https://proj.supabase.co'")
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
it('SQL schema selector qualifies the table in the generated route', () => {
|
|
1959
|
+
let p = createProject([customers])
|
|
1960
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres', schema: 'analytics' })
|
|
1961
|
+
const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!.contents
|
|
1962
|
+
expect(route).toContain('dbSchema: "analytics"')
|
|
1963
|
+
// No schema set -> no dbSchema option (unqualified table).
|
|
1964
|
+
let q = createProject([customers])
|
|
1965
|
+
q = setEntityDataSource(q, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1966
|
+
expect(byPathOf(emitStudioProject(q), 'src/routes/api/customers/+server.ts')!.contents).not.toContain('dbSchema:')
|
|
1967
|
+
})
|
|
1272
1968
|
})
|
|
1273
1969
|
|
|
1274
1970
|
describe('grid editing (a Grid property) -> codegen', () => {
|
|
@@ -1499,6 +2195,50 @@ describe('grid editing (a Grid property) -> codegen', () => {
|
|
|
1499
2195
|
expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
|
|
1500
2196
|
})
|
|
1501
2197
|
|
|
2198
|
+
it('xlsx / pdf / print buttons go through @svgrid/enterprise and compile', () => {
|
|
2199
|
+
let p = createProject([customers])
|
|
2200
|
+
const sid = p.screens[0]!.id
|
|
2201
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
2202
|
+
p = updateBlock(p, sid, gid, { config: { export: { xlsx: true, pdf: true, print: true } } as Partial<import('./project').BlockConfig> })
|
|
2203
|
+
const page = pageFor(p)
|
|
2204
|
+
// Imported from the enterprise pack, not the free grid api.
|
|
2205
|
+
expect(page).toMatch(/import \{[^}]*exportGrid[^}]*\} from '@svgrid\/enterprise'/)
|
|
2206
|
+
expect(page).toMatch(/import \{[^}]*printGrid[^}]*\} from '@svgrid\/enterprise'/)
|
|
2207
|
+
expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'xlsx', 'customers')")
|
|
2208
|
+
expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'pdf', 'customers')")
|
|
2209
|
+
expect(page).toContain('stPrint(gridApi_' + gid.replace(/-/g, '_') + ", 'customers')")
|
|
2210
|
+
// Helpers emitted exactly once each, beside the buttons that call them.
|
|
2211
|
+
expect(page.match(/async function stExport\(/g)?.length).toBe(1)
|
|
2212
|
+
expect(page.match(/async function stPrint\(/g)?.length).toBe(1)
|
|
2213
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
2214
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
2215
|
+
}
|
|
2216
|
+
})
|
|
2217
|
+
|
|
2218
|
+
it('declares the optional peer deps the enterprise exporters need', () => {
|
|
2219
|
+
const base = createProject([customers])
|
|
2220
|
+
const sid = base.screens[0]!.id
|
|
2221
|
+
const gid = base.screens[0]!.blocks[0]!.id
|
|
2222
|
+
const pkgOf = (proj: typeof base) => {
|
|
2223
|
+
const f = emitStudioAppBundle(proj).find((f) => f.path === 'package.json')!
|
|
2224
|
+
return JSON.parse(f.contents).dependencies as Record<string, string>
|
|
2225
|
+
}
|
|
2226
|
+
// jszip (xlsx) and pdfmake (pdf) are imported lazily INSIDE @svgrid/enterprise,
|
|
2227
|
+
// so they never appear in the generated source - they must come from the config.
|
|
2228
|
+
const xlsxDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { xlsx: true } } as Partial<import('./project').BlockConfig> }))
|
|
2229
|
+
expect(xlsxDeps).toHaveProperty('jszip')
|
|
2230
|
+
expect(xlsxDeps).not.toHaveProperty('pdfmake')
|
|
2231
|
+
|
|
2232
|
+
const pdfDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { pdf: true } } as Partial<import('./project').BlockConfig> }))
|
|
2233
|
+
expect(pdfDeps).toHaveProperty('pdfmake')
|
|
2234
|
+
expect(pdfDeps).not.toHaveProperty('jszip')
|
|
2235
|
+
|
|
2236
|
+
// A csv-only app drags in neither.
|
|
2237
|
+
const csvDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { csv: true } } as Partial<import('./project').BlockConfig> }))
|
|
2238
|
+
expect(csvDeps).not.toHaveProperty('jszip')
|
|
2239
|
+
expect(csvDeps).not.toHaveProperty('pdfmake')
|
|
2240
|
+
})
|
|
2241
|
+
|
|
1502
2242
|
it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
|
|
1503
2243
|
let p = createProject([customers])
|
|
1504
2244
|
const sid = p.screens[0]!.id
|
|
@@ -1710,7 +2450,7 @@ describe('dock layout (screen.layout = dock)', () => {
|
|
|
1710
2450
|
const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1711
2451
|
expect(page).toContain('async function save({ mode, id, values }')
|
|
1712
2452
|
expect(page).toContain('const row = values')
|
|
1713
|
-
expect(page).toContain('const ctx = { grid:
|
|
2453
|
+
expect(page).toContain('const ctx = { grid: gridCtx,')
|
|
1714
2454
|
expect(page).toContain("ctx.goto('/customers')")
|
|
1715
2455
|
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1716
2456
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
@@ -1855,6 +2595,35 @@ describe('emitStudioAppBundle (full runnable app)', () => {
|
|
|
1855
2595
|
expect(pkg.devDependencies['vite']).toBeTruthy()
|
|
1856
2596
|
})
|
|
1857
2597
|
|
|
2598
|
+
it('schedules jobs with the platform: vercel.json crons on Vercel, a workflow elsewhere', () => {
|
|
2599
|
+
let base = createProject([customers])
|
|
2600
|
+
base = setJob(base, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'code', code: 'void 0' })
|
|
2601
|
+
|
|
2602
|
+
const vercel = emitStudioAppBundle(setDeployTarget(base, 'vercel'))
|
|
2603
|
+
const vj = vercel.find((f) => f.path === 'vercel.json')!
|
|
2604
|
+
expect(JSON.parse(vj.contents).crons).toEqual([{ path: '/api/cron?job=digest', schedule: '0 6 * * *' }])
|
|
2605
|
+
// Vercel Cron calls the route itself - no Actions workflow needed.
|
|
2606
|
+
expect(vercel.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
|
|
2607
|
+
|
|
2608
|
+
const node = emitStudioAppBundle(setDeployTarget(base, 'node'))
|
|
2609
|
+
const wf = node.find((f) => f.path === '.github/workflows/cron.yml')!.contents
|
|
2610
|
+
expect(wf).toContain("- cron: '0 6 * * *'")
|
|
2611
|
+
// The endpoint lives in the CRON_URL secret; the workflow appends the job.
|
|
2612
|
+
expect(wf).toContain('"$CRON_URL?job=digest"')
|
|
2613
|
+
// Inert until the secrets exist, so CI is green before it is configured.
|
|
2614
|
+
expect(wf).toContain("secrets.CRON_URL != ''")
|
|
2615
|
+
expect(node.find((f) => f.path === 'vercel.json')).toBeUndefined()
|
|
2616
|
+
|
|
2617
|
+
// The secret the route demands is documented in .env.example.
|
|
2618
|
+
expect(node.find((f) => f.path === '.env.example')!.contents).toContain('CRON_SECRET=')
|
|
2619
|
+
})
|
|
2620
|
+
|
|
2621
|
+
it('emits no schedule config when there are no jobs', () => {
|
|
2622
|
+
const files = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
|
|
2623
|
+
expect(files.find((f) => f.path === 'vercel.json')).toBeUndefined()
|
|
2624
|
+
expect(files.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
|
|
2625
|
+
})
|
|
2626
|
+
|
|
1858
2627
|
it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
|
|
1859
2628
|
const bundle = emitStudioAppBundle(createProject([customers]))
|
|
1860
2629
|
const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
|
|
@@ -2043,7 +2812,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2043
2812
|
expect(page.contents).toContain("import * as handlers from './handlers'")
|
|
2044
2813
|
// onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
|
|
2045
2814
|
// the settable data battery, since this freestanding page owns its rows).
|
|
2046
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2815
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
|
|
2047
2816
|
expect(page.contents).toContain('as PageContext')
|
|
2048
2817
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2049
2818
|
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
@@ -2074,10 +2843,12 @@ describe('code companion (design + your own code)', () => {
|
|
|
2074
2843
|
const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
|
|
2075
2844
|
// Named handle (button1) created + wired into the component markup.
|
|
2076
2845
|
expect(page.contents).toContain('const button1 = handle(')
|
|
2077
|
-
|
|
2078
|
-
|
|
2846
|
+
// Click is wired through the component's own onclick callback prop (exact
|
|
2847
|
+
// semantics), not a bubbling wrapper listener.
|
|
2848
|
+
expect(page.contents).toContain("<SvButton {...button1.props} onclick={(e) => button1.fire('click', e)}>{button1.text}</SvButton>")
|
|
2849
|
+
expect(page.contents).toContain("import { handle, gridHandle } from '$lib/handles.svelte'")
|
|
2079
2850
|
expect(page.contents).toContain('button1.fire(\'click\', e)')
|
|
2080
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2851
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, button1, data:')
|
|
2081
2852
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2082
2853
|
// Typed handle: ButtonHandle intersects Handle with the button's real setters.
|
|
2083
2854
|
expect(ctx.contents).toContain('button1: ButtonHandle')
|
|
@@ -2177,7 +2948,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2177
2948
|
expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
|
|
2178
2949
|
expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
|
|
2179
2950
|
// Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
|
|
2180
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2951
|
+
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')
|
|
2181
2952
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2182
2953
|
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
2183
2954
|
// The grid api is typed to the entity's row (Customers), not any.
|
|
@@ -2203,7 +2974,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2203
2974
|
expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
|
|
2204
2975
|
expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
|
|
2205
2976
|
expect(page.contents).toContain('rows={chart1.rows}')
|
|
2206
|
-
expect(page.contents).toContain("import { handle, dataHandle } from '$lib/handles.svelte'")
|
|
2977
|
+
expect(page.contents).toContain("import { handle, dataHandle, gridHandle } from '$lib/handles.svelte'")
|
|
2207
2978
|
// ctx carries the data handles + the entity component handle + batteries.
|
|
2208
2979
|
expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
|
|
2209
2980
|
expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')
|
|
@@ -2343,7 +3114,12 @@ describe('code companion (design + your own code)', () => {
|
|
|
2343
3114
|
expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
|
|
2344
3115
|
// An unknown ctx member is caught.
|
|
2345
3116
|
expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
|
|
2346
|
-
|
|
3117
|
+
// This test runs REAL TypeScript programs over the generated code against
|
|
3118
|
+
// the grid's .d.ts, so it scales with the public type surface and is far
|
|
3119
|
+
// slower than a normal unit test. It passes comfortably in isolation but was
|
|
3120
|
+
// brushing the default 5s budget under full-suite parallel load; every grid
|
|
3121
|
+
// API added since made that worse. Give the compile room.
|
|
3122
|
+
}, 30_000)
|
|
2347
3123
|
|
|
2348
3124
|
it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
|
|
2349
3125
|
let p = createProject([customers])
|