@svgrid/enterprise 2.3.1 → 2.3.2
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/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
- package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
- package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
- package/dist/cdn/svgrid-enterprise.svelte-external.js +7540 -7511
- package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
- package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
- package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
- package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
- package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
- package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
- package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
- package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
- package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
- package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
- package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
- package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
- package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
- package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
- package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
- package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
- package/dist/designer/assets/easing-zaypKrr0.js +3 -0
- package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
- package/dist/designer/assets/{index-DsDgp9Xq.js → index-DVrv9dH6.js} +141 -3102
- package/dist/designer/assets/index-DjPp1mn7.css +1 -0
- package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
- package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
- package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
- package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
- package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
- package/dist/designer/assets/src-BRKu53tV.js +2842 -0
- package/dist/designer/assets/src-DseB1VRN.css +1 -0
- package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
- package/dist/designer/index.html +21 -2
- package/dist/node/studio.js +220 -27
- package/package.json +5 -5
- package/src/sources/introspect-supabase.ts +2 -2
- package/src/studio/emit-project.ts +1 -1
- package/src/studio/init-flow.test.ts +364 -239
- package/src/studio/init-flow.ts +409 -358
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +0 -7
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +0 -10
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +0 -1
- package/dist/designer/assets/index-tTY_Dx4P.css +0 -1
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +0 -2
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +0 -242
- package/dist/designer/assets/smart.export-BZlSCE8T.js +0 -35
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +0 -1
|
@@ -1,239 +1,364 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
|
|
3
|
-
import type { StudioIO } from './cli.js'
|
|
4
|
-
import { parseProject, validateProject, type GridConfig } from './project.js'
|
|
5
|
-
|
|
6
|
-
/** A scripted terminal: answers are consumed in order, defaults fill the rest. */
|
|
7
|
-
function scriptedPrompts(answers: string[]) {
|
|
8
|
-
const asked: string[] = []
|
|
9
|
-
const said: string[] = []
|
|
10
|
-
const queue = [...answers]
|
|
11
|
-
const io: PromptIO = {
|
|
12
|
-
ask: async (question, def) => {
|
|
13
|
-
asked.push(question)
|
|
14
|
-
const next = queue.shift()
|
|
15
|
-
return next === undefined ? (def ?? '') : next
|
|
16
|
-
},
|
|
17
|
-
say: (line) => said.push(line),
|
|
18
|
-
}
|
|
19
|
-
return { io, asked, said, get remaining() { return queue.length } }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** An in-memory filesystem. */
|
|
23
|
-
function memoryIo() {
|
|
24
|
-
const files = new Map<string, string>()
|
|
25
|
-
const io: StudioIO = {
|
|
26
|
-
readFile: async (path) => files.get(path) ?? null,
|
|
27
|
-
writeFile: async (path, contents) => { files.set(path, contents) },
|
|
28
|
-
}
|
|
29
|
-
return { io, files }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A fake Postgres holding two tables, answering the real catalog queries
|
|
33
|
-
* (which alias their columns to name / type / nullable / pk). */
|
|
34
|
-
function fakeDb(): DbGateway {
|
|
35
|
-
const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
|
|
36
|
-
customers: [
|
|
37
|
-
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
38
|
-
{ name: 'name', type: 'text', nullable: 0, pk: 0 },
|
|
39
|
-
{ name: 'city', type: 'text', nullable: 1, pk: 0 },
|
|
40
|
-
],
|
|
41
|
-
orders: [
|
|
42
|
-
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
43
|
-
{ name: 'total', type: 'numeric', nullable: 1, pk: 0 },
|
|
44
|
-
],
|
|
45
|
-
}
|
|
46
|
-
return {
|
|
47
|
-
ensureDriver: async () => ({ ok: true }),
|
|
48
|
-
connect: async () => async (sql: string, params: unknown[]) => {
|
|
49
|
-
if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
|
|
50
|
-
if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
|
|
51
|
-
if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
|
|
52
|
-
return [] // no foreign keys
|
|
53
|
-
},
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
expect(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
expect(result.
|
|
124
|
-
expect(result.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
})
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
|
|
3
|
+
import type { StudioIO } from './cli.js'
|
|
4
|
+
import { parseProject, validateProject, type GridConfig } from './project.js'
|
|
5
|
+
|
|
6
|
+
/** A scripted terminal: answers are consumed in order, defaults fill the rest. */
|
|
7
|
+
function scriptedPrompts(answers: string[]) {
|
|
8
|
+
const asked: string[] = []
|
|
9
|
+
const said: string[] = []
|
|
10
|
+
const queue = [...answers]
|
|
11
|
+
const io: PromptIO = {
|
|
12
|
+
ask: async (question, def) => {
|
|
13
|
+
asked.push(question)
|
|
14
|
+
const next = queue.shift()
|
|
15
|
+
return next === undefined ? (def ?? '') : next
|
|
16
|
+
},
|
|
17
|
+
say: (line) => said.push(line),
|
|
18
|
+
}
|
|
19
|
+
return { io, asked, said, get remaining() { return queue.length } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An in-memory filesystem. */
|
|
23
|
+
function memoryIo() {
|
|
24
|
+
const files = new Map<string, string>()
|
|
25
|
+
const io: StudioIO = {
|
|
26
|
+
readFile: async (path) => files.get(path) ?? null,
|
|
27
|
+
writeFile: async (path, contents) => { files.set(path, contents) },
|
|
28
|
+
}
|
|
29
|
+
return { io, files }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fake Postgres holding two tables, answering the real catalog queries
|
|
33
|
+
* (which alias their columns to name / type / nullable / pk). */
|
|
34
|
+
function fakeDb(): DbGateway {
|
|
35
|
+
const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
|
|
36
|
+
customers: [
|
|
37
|
+
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
38
|
+
{ name: 'name', type: 'text', nullable: 0, pk: 0 },
|
|
39
|
+
{ name: 'city', type: 'text', nullable: 1, pk: 0 },
|
|
40
|
+
],
|
|
41
|
+
orders: [
|
|
42
|
+
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
43
|
+
{ name: 'total', type: 'numeric', nullable: 1, pk: 0 },
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ensureDriver: async () => ({ ok: true }),
|
|
48
|
+
connect: async () => async (sql: string, params: unknown[]) => {
|
|
49
|
+
if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
|
|
50
|
+
if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
|
|
51
|
+
if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
|
|
52
|
+
return [] // no foreign keys
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stand in for a Supabase project: PostgREST serves its OpenAPI doc at /rest/v1/. */
|
|
58
|
+
function stubSupabase(definitions: Record<string, unknown> | null) {
|
|
59
|
+
vi.stubGlobal('fetch', async (input: string | URL) => {
|
|
60
|
+
const href = String(input)
|
|
61
|
+
if (href.endsWith('/rest/v1/')) {
|
|
62
|
+
if (!definitions) return new Response('nope', { status: 401 })
|
|
63
|
+
return new Response(JSON.stringify({ definitions }), { status: 200, headers: { 'content-type': 'application/json' } })
|
|
64
|
+
}
|
|
65
|
+
// Row-sample and CSV fallbacks: answer empty so only the OpenAPI path counts.
|
|
66
|
+
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SUPABASE_DEFS = {
|
|
71
|
+
customers: {
|
|
72
|
+
required: ['id', 'name'],
|
|
73
|
+
properties: {
|
|
74
|
+
id: { type: 'integer', description: 'Note:\nThis is a Primary Key.<pk/>' },
|
|
75
|
+
name: { type: 'string' },
|
|
76
|
+
tier: { type: 'string', enum: ['free', 'pro'] },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
orders: {
|
|
80
|
+
required: ['id'],
|
|
81
|
+
properties: {
|
|
82
|
+
id: { type: 'integer', description: '<pk/>' },
|
|
83
|
+
total: { type: 'number' },
|
|
84
|
+
customer_id: { type: 'integer', description: "<fk table='customers' column='id'/>" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('parseSelection', () => {
|
|
90
|
+
const items = ['customers', 'orders', 'invoices']
|
|
91
|
+
|
|
92
|
+
it('takes everything by default', () => {
|
|
93
|
+
expect(parseSelection('', items)).toEqual(items)
|
|
94
|
+
expect(parseSelection('all', items)).toEqual(items)
|
|
95
|
+
expect(parseSelection('*', items)).toEqual(items)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('takes nothing for "none"', () => {
|
|
99
|
+
expect(parseSelection('none', items)).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('accepts names, numbers, and a mix - without duplicates', () => {
|
|
103
|
+
expect(parseSelection('orders', items)).toEqual(['orders'])
|
|
104
|
+
expect(parseSelection('1, 3', items)).toEqual(['customers', 'invoices'])
|
|
105
|
+
expect(parseSelection('2, invoices', items)).toEqual(['orders', 'invoices'])
|
|
106
|
+
expect(parseSelection('orders, 2', items)).toEqual(['orders'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('ignores names and numbers that do not exist', () => {
|
|
110
|
+
expect(parseSelection('nope, 9, orders', items)).toEqual(['orders'])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('runStudioInit', () => {
|
|
115
|
+
it('builds a runnable app from a starter dataset with all defaults', async () => {
|
|
116
|
+
const prompts = scriptedPrompts([])
|
|
117
|
+
const fs = memoryIo()
|
|
118
|
+
const result = await runStudioInit({ yes: true }, prompts.io, null, fs.io)
|
|
119
|
+
|
|
120
|
+
expect(result.project.entities.length).toBeGreaterThanOrEqual(2)
|
|
121
|
+
expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
|
|
122
|
+
expect(result.written).toContain('studio.config.json')
|
|
123
|
+
expect(result.written.some((p) => p.endsWith('package.json'))).toBe(true)
|
|
124
|
+
expect(result.written.some((p) => p.includes('+page.svelte'))).toBe(true)
|
|
125
|
+
// --yes asks nothing.
|
|
126
|
+
expect(prompts.asked).toEqual([])
|
|
127
|
+
// The written config re-parses into the same project.
|
|
128
|
+
expect(parseProject(fs.files.get('studio.config.json')!)).toEqual(result.project)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('asks the questions in order when driven interactively', async () => {
|
|
132
|
+
const prompts = scriptedPrompts([
|
|
133
|
+
'1', // source: sample data
|
|
134
|
+
'2', // dataset: products & categories
|
|
135
|
+
'y', // full CRUD suite
|
|
136
|
+
'1', // editing: popup form
|
|
137
|
+
'1', // theme
|
|
138
|
+
'n', // dark mode
|
|
139
|
+
'Catalog', // app name
|
|
140
|
+
'.', // out dir
|
|
141
|
+
])
|
|
142
|
+
const fs = memoryIo()
|
|
143
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
144
|
+
|
|
145
|
+
expect(prompts.asked).toEqual([
|
|
146
|
+
'Pick a number:', // source
|
|
147
|
+
'Pick a number:', // dataset
|
|
148
|
+
'\nGenerate a list, an edit form and a record page for each table? (Y/n)',
|
|
149
|
+
'Pick a number:', // editing mode
|
|
150
|
+
'Pick a number:', // theme
|
|
151
|
+
'Dark mode? (y/N)',
|
|
152
|
+
'\nApp name:',
|
|
153
|
+
'Write it where?',
|
|
154
|
+
])
|
|
155
|
+
expect(result.project.title).toBe('Catalog')
|
|
156
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['categories', 'products'])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('honours the out directory', async () => {
|
|
160
|
+
const prompts = scriptedPrompts([])
|
|
161
|
+
const fs = memoryIo()
|
|
162
|
+
const result = await runStudioInit({ yes: true, out: 'apps/shop' }, prompts.io, null, fs.io)
|
|
163
|
+
|
|
164
|
+
expect(result.written.every((p) => p.startsWith('apps/shop/'))).toBe(true)
|
|
165
|
+
expect(result.nextSteps[0]).toBe('cd apps/shop')
|
|
166
|
+
expect(fs.files.has('apps/shop/studio.config.json')).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('stores a dataset in PGlite when that source is picked', async () => {
|
|
170
|
+
// Source menu: 1 sample, 2 database, 3 supabase, 4 pglite, 5 rest.
|
|
171
|
+
const prompts = scriptedPrompts(['4', '1', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
172
|
+
const fs = memoryIo()
|
|
173
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
174
|
+
|
|
175
|
+
expect(result.project.dataSource).toBe('pglite')
|
|
176
|
+
const first = result.project.entities[0]!.name
|
|
177
|
+
expect(result.project.dataSources?.[first]).toMatchObject({ kind: 'pglite', table: first })
|
|
178
|
+
// The seed rides along, so the generated app boots with data.
|
|
179
|
+
expect((result.project.dataSources?.[first] as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('reads tables from a live database', async () => {
|
|
183
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Ops', '.'])
|
|
184
|
+
const fs = memoryIo()
|
|
185
|
+
const result = await runStudioInit(
|
|
186
|
+
{ db: 'postgres', url: 'postgres://localhost/app' },
|
|
187
|
+
prompts.io,
|
|
188
|
+
fakeDb(),
|
|
189
|
+
fs.io,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
|
|
193
|
+
expect(result.project.dataSource).toBe('sql')
|
|
194
|
+
expect(result.project.dataSources?.customers).toEqual({ kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
195
|
+
// --db + --url skip the connection questions entirely.
|
|
196
|
+
expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('imports only the picked tables', async () => {
|
|
200
|
+
const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Ops', '.'])
|
|
201
|
+
const fs = memoryIo()
|
|
202
|
+
const result = await runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io)
|
|
203
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['orders'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('fails clearly when the driver cannot be installed', async () => {
|
|
207
|
+
const prompts = scriptedPrompts(['all'])
|
|
208
|
+
const fs = memoryIo()
|
|
209
|
+
const db: DbGateway = { ...fakeDb(), ensureDriver: async () => ({ ok: false, message: 'pg is not installed' }) }
|
|
210
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, db, fs.io))
|
|
211
|
+
.rejects.toThrow('pg is not installed')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('fails clearly when no table is picked', async () => {
|
|
215
|
+
const prompts = scriptedPrompts(['none'])
|
|
216
|
+
const fs = memoryIo()
|
|
217
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io))
|
|
218
|
+
.rejects.toThrow('No tables picked')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('refuses a database when no gateway is available', async () => {
|
|
222
|
+
const prompts = scriptedPrompts([])
|
|
223
|
+
const fs = memoryIo()
|
|
224
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, null, fs.io))
|
|
225
|
+
.rejects.toThrow('not available here')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('rejects an unknown dataset id by name', async () => {
|
|
229
|
+
const prompts = scriptedPrompts([])
|
|
230
|
+
const fs = memoryIo()
|
|
231
|
+
await expect(runStudioInit({ yes: true, dataset: 'nope' }, prompts.io, null, fs.io))
|
|
232
|
+
.rejects.toThrow(/Unknown dataset "nope"/)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('builds an app from a REST endpoint', async () => {
|
|
236
|
+
const prompts = scriptedPrompts(['5', 'https://api.example.com/widgets', 'widgets', 'y', '1', '1', 'n', 'API app', '.'])
|
|
237
|
+
const fs = memoryIo()
|
|
238
|
+
const rows = JSON.stringify({ data: [{ id: 1, name: 'Widget', price: 9.5 }] })
|
|
239
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io, async () => rows)
|
|
240
|
+
|
|
241
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['widgets'])
|
|
242
|
+
expect(result.project.dataSources?.widgets).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com', path: 'widgets' })
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('applies the picked editing mode to the generated grids', async () => {
|
|
246
|
+
const prompts = scriptedPrompts(['1', '1', 'y', '2', '1', 'n', 'Inline', '.'])
|
|
247
|
+
const fs = memoryIo()
|
|
248
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
249
|
+
|
|
250
|
+
const listScreen = result.project.screens.find((s) => s.id === result.project.entities[0]!.name)!
|
|
251
|
+
const grid = listScreen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
|
|
252
|
+
expect(grid.editing).toBe('inline')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('supabase', () => {
|
|
256
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
257
|
+
|
|
258
|
+
it('reads a project over the REST API and binds each table to it', async () => {
|
|
259
|
+
stubSupabase(SUPABASE_DEFS)
|
|
260
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
261
|
+
const fs = memoryIo()
|
|
262
|
+
const result = await runStudioInit(
|
|
263
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'anon-key' },
|
|
264
|
+
prompts.io,
|
|
265
|
+
null, // no DbGateway: the point is that Supabase needs no driver
|
|
266
|
+
fs.io,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
|
|
270
|
+
expect(result.project.dataSource).toBe('supabase')
|
|
271
|
+
expect(result.project.dataSources?.customers).toEqual({
|
|
272
|
+
kind: 'supabase', table: 'customers', url: 'https://abc.supabase.co', key: 'anon-key',
|
|
273
|
+
})
|
|
274
|
+
expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
|
|
275
|
+
// The URL + key flags skip straight to the table question.
|
|
276
|
+
expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('picks up the primary key and the foreign key from the API doc', async () => {
|
|
280
|
+
stubSupabase(SUPABASE_DEFS)
|
|
281
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
282
|
+
const fs = memoryIo()
|
|
283
|
+
const { project } = await runStudioInit(
|
|
284
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
285
|
+
)
|
|
286
|
+
const orders = project.entities.find((e) => e.name === 'orders')!
|
|
287
|
+
expect(orders.fields.find((f) => f.field === 'id')?.primaryKey).toBe(true)
|
|
288
|
+
expect(orders.fields.find((f) => f.field === 'customer_id')?.relation?.entity).toBe('customers')
|
|
289
|
+
// A relation means the customers detail page gets an orders tab.
|
|
290
|
+
expect(project.screens.some((s) => s.id === 'customers-detail')).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('imports only the picked tables', async () => {
|
|
294
|
+
stubSupabase(SUPABASE_DEFS)
|
|
295
|
+
const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
296
|
+
const fs = memoryIo()
|
|
297
|
+
const { project } = await runStudioInit(
|
|
298
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
299
|
+
)
|
|
300
|
+
expect(project.entities.map((e) => e.name)).toEqual(['orders'])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('falls back to typed table names when the API doc is restricted', async () => {
|
|
304
|
+
// 401 on the doc, but each table still introspects from a sample row.
|
|
305
|
+
let calls = 0
|
|
306
|
+
vi.stubGlobal('fetch', async (input: string | URL) => {
|
|
307
|
+
const href = String(input)
|
|
308
|
+
calls++
|
|
309
|
+
if (href.endsWith('/rest/v1/')) return new Response('no', { status: 401 })
|
|
310
|
+
return new Response(JSON.stringify([{ id: 1, name: 'Acme' }]), {
|
|
311
|
+
status: 200, headers: { 'content-type': 'application/json' },
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
const prompts = scriptedPrompts(['customers', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
315
|
+
const fs = memoryIo()
|
|
316
|
+
const { project } = await runStudioInit(
|
|
317
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
318
|
+
)
|
|
319
|
+
expect(calls).toBeGreaterThan(0)
|
|
320
|
+
expect(project.entities.map((e) => e.name)).toEqual(['customers'])
|
|
321
|
+
expect(project.entities[0]!.fields.map((f) => f.field)).toContain('name')
|
|
322
|
+
expect(prompts.asked).toContain('Table names (comma separated):')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('requires both the URL and the key', async () => {
|
|
326
|
+
const prompts = scriptedPrompts(['', ''])
|
|
327
|
+
const fs = memoryIo()
|
|
328
|
+
await expect(runStudioInit({ supabaseUrl: 'https://abc.supabase.co' }, prompts.io, null, fs.io))
|
|
329
|
+
.rejects.toThrow('project URL and the anon key are required')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('fails clearly when no picked table can be read', async () => {
|
|
333
|
+
// Doc lists nothing AND every per-table fallback 404s.
|
|
334
|
+
vi.stubGlobal('fetch', async (input: string | URL) =>
|
|
335
|
+
String(input).endsWith('/rest/v1/')
|
|
336
|
+
? new Response(JSON.stringify({ definitions: {} }), { status: 200, headers: { 'content-type': 'application/json' } })
|
|
337
|
+
: new Response('missing', { status: 404 }),
|
|
338
|
+
)
|
|
339
|
+
const prompts = scriptedPrompts(['ghosts'])
|
|
340
|
+
const fs = memoryIo()
|
|
341
|
+
await expect(runStudioInit(
|
|
342
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
343
|
+
)).rejects.toThrow('None of the picked tables could be read')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('applies the chosen theme', async () => {
|
|
348
|
+
const prompts = scriptedPrompts([])
|
|
349
|
+
const fs = memoryIo()
|
|
350
|
+
const result = await runStudioInit({ yes: true, theme: 'material', dark: true }, prompts.io, null, fs.io)
|
|
351
|
+
expect(result.project.theme?.preset).toBe('material')
|
|
352
|
+
expect(result.project.theme?.mode).toBe('dark')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('keeps user-owned files that already exist', async () => {
|
|
356
|
+
const fs = memoryIo()
|
|
357
|
+
await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
|
|
358
|
+
const handlers = [...fs.files.keys()].find((p) => p.endsWith('handlers.ts'))
|
|
359
|
+
if (!handlers) return // no user-owned companion in this project shape
|
|
360
|
+
fs.files.set(handlers, '// my own code')
|
|
361
|
+
await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
|
|
362
|
+
expect(fs.files.get(handlers)).toBe('// my own code')
|
|
363
|
+
})
|
|
364
|
+
})
|