@tanstack/cli 0.60.1 → 0.61.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/CHANGELOG.md +28 -0
- package/dist/cli.js +266 -11
- package/dist/command-line.js +103 -8
- package/dist/discovery.js +144 -0
- package/dist/options.js +35 -2
- package/dist/types/command-line.d.ts +7 -0
- package/dist/types/{mcp/types.d.ts → discovery.d.ts} +23 -75
- package/dist/types/types.d.ts +1 -2
- package/dist/types/ui-prompts.d.ts +5 -0
- package/dist/ui-prompts.js +26 -0
- package/package.json +2 -5
- package/playwright-report/index.html +1 -1
- package/src/cli.ts +345 -13
- package/src/command-line.ts +147 -8
- package/src/discovery.ts +209 -0
- package/src/options.ts +46 -0
- package/src/types.ts +1 -2
- package/src/ui-prompts.ts +32 -0
- package/tests/command-line.test.ts +81 -0
- package/tests/options.test.ts +65 -0
- package/tests/ui-prompts.test.ts +28 -0
- package/tests-e2e/create-smoke.spec.ts +12 -12
- package/tests-e2e/router-only-smoke.spec.ts +11 -25
- package/tests-e2e/solid-smoke.spec.ts +3 -2
- package/dist/mcp/api.js +0 -31
- package/dist/mcp/tools.js +0 -250
- package/dist/mcp/types.js +0 -37
- package/dist/mcp.js +0 -181
- package/dist/types/mcp/api.d.ts +0 -4
- package/dist/types/mcp/tools.d.ts +0 -2
- package/dist/types/mcp.d.ts +0 -5
- package/src/mcp/api.ts +0 -42
- package/src/mcp/tools.ts +0 -323
- package/src/mcp/types.ts +0 -46
- package/src/mcp.ts +0 -263
- package/tests/mcp.test.ts +0 -225
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const TANSTACK_API_BASE = 'https://tanstack.com/api/data'
|
|
4
|
+
|
|
5
|
+
const LibrarySchema = z.object({
|
|
6
|
+
id: z.string(),
|
|
7
|
+
name: z.string(),
|
|
8
|
+
tagline: z.string(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
frameworks: z.array(z.string()),
|
|
11
|
+
latestVersion: z.string(),
|
|
12
|
+
latestBranch: z.string().optional(),
|
|
13
|
+
availableVersions: z.array(z.string()),
|
|
14
|
+
repo: z.string(),
|
|
15
|
+
docsRoot: z.string().optional(),
|
|
16
|
+
defaultDocs: z.string().optional(),
|
|
17
|
+
docsUrl: z.string().optional(),
|
|
18
|
+
githubUrl: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const LibrariesResponseSchema = z.object({
|
|
22
|
+
libraries: z.array(LibrarySchema),
|
|
23
|
+
groups: z.record(z.array(z.string())),
|
|
24
|
+
groupNames: z.record(z.string()),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const PartnerSchema = z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
name: z.string(),
|
|
30
|
+
tagline: z.string().optional(),
|
|
31
|
+
description: z.string(),
|
|
32
|
+
category: z.string(),
|
|
33
|
+
categoryLabel: z.string(),
|
|
34
|
+
libraries: z.array(z.string()),
|
|
35
|
+
url: z.string(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const PartnersResponseSchema = z.object({
|
|
39
|
+
partners: z.array(PartnerSchema),
|
|
40
|
+
categories: z.array(z.string()),
|
|
41
|
+
categoryLabels: z.record(z.string()),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const LIBRARY_GROUPS = ['state', 'headlessUI', 'performance', 'tooling'] as const
|
|
45
|
+
|
|
46
|
+
// Algolia config (public read-only keys)
|
|
47
|
+
const ALGOLIA_APP_ID = 'FQ0DQ6MA3C'
|
|
48
|
+
const ALGOLIA_API_KEY = '10c34d6a5c89f6048cf644d601e65172'
|
|
49
|
+
const ALGOLIA_INDEX = 'tanstack-test'
|
|
50
|
+
|
|
51
|
+
export type LibrariesResponse = z.infer<typeof LibrariesResponseSchema>
|
|
52
|
+
export type PartnersResponse = z.infer<typeof PartnersResponseSchema>
|
|
53
|
+
|
|
54
|
+
export async function fetchLibraries(): Promise<LibrariesResponse> {
|
|
55
|
+
const response = await fetch(`${TANSTACK_API_BASE}/libraries`)
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Failed to fetch libraries: ${response.statusText}`)
|
|
58
|
+
}
|
|
59
|
+
const data = await response.json()
|
|
60
|
+
return LibrariesResponseSchema.parse(data)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function fetchPartners(): Promise<PartnersResponse> {
|
|
64
|
+
const response = await fetch(`${TANSTACK_API_BASE}/partners`)
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`Failed to fetch partners: ${response.statusText}`)
|
|
67
|
+
}
|
|
68
|
+
const data = await response.json()
|
|
69
|
+
return PartnersResponseSchema.parse(data)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchDocContent(
|
|
73
|
+
repo: string,
|
|
74
|
+
branch: string,
|
|
75
|
+
filePath: string,
|
|
76
|
+
): Promise<string | null> {
|
|
77
|
+
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`
|
|
78
|
+
const response = await fetch(url, {
|
|
79
|
+
headers: { 'User-Agent': 'tanstack-cli' },
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
if (response.status === 404) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Failed to fetch doc: ${response.statusText}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return response.text()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function searchTanStackDocs({
|
|
93
|
+
query,
|
|
94
|
+
library,
|
|
95
|
+
framework,
|
|
96
|
+
limit = 10,
|
|
97
|
+
}: {
|
|
98
|
+
query: string
|
|
99
|
+
library?: string
|
|
100
|
+
framework?: string
|
|
101
|
+
limit?: number
|
|
102
|
+
}): Promise<{
|
|
103
|
+
query: string
|
|
104
|
+
totalHits: number
|
|
105
|
+
results: Array<{
|
|
106
|
+
title: string
|
|
107
|
+
url: string
|
|
108
|
+
snippet: string
|
|
109
|
+
library: string
|
|
110
|
+
breadcrumb: Array<string>
|
|
111
|
+
}>
|
|
112
|
+
}> {
|
|
113
|
+
const ALL_LIBRARIES = [
|
|
114
|
+
'config',
|
|
115
|
+
'form',
|
|
116
|
+
'optimistic',
|
|
117
|
+
'pacer',
|
|
118
|
+
'query',
|
|
119
|
+
'ranger',
|
|
120
|
+
'react-charts',
|
|
121
|
+
'router',
|
|
122
|
+
'start',
|
|
123
|
+
'store',
|
|
124
|
+
'table',
|
|
125
|
+
'virtual',
|
|
126
|
+
'db',
|
|
127
|
+
'devtools',
|
|
128
|
+
]
|
|
129
|
+
const ALL_FRAMEWORKS = ['react', 'vue', 'solid', 'svelte', 'angular']
|
|
130
|
+
|
|
131
|
+
const filterParts: Array<string> = ['version:latest']
|
|
132
|
+
|
|
133
|
+
if (library) {
|
|
134
|
+
const otherLibraries = ALL_LIBRARIES.filter((l) => l !== library)
|
|
135
|
+
const exclusions = otherLibraries.map((l) => `NOT library:${l}`).join(' AND ')
|
|
136
|
+
if (exclusions) filterParts.push(`(${exclusions})`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (framework) {
|
|
140
|
+
const otherFrameworks = ALL_FRAMEWORKS.filter((f) => f !== framework)
|
|
141
|
+
const exclusions = otherFrameworks.map((f) => `NOT framework:${f}`).join(' AND ')
|
|
142
|
+
if (exclusions) filterParts.push(`(${exclusions})`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const searchParams = {
|
|
146
|
+
requests: [
|
|
147
|
+
{
|
|
148
|
+
indexName: ALGOLIA_INDEX,
|
|
149
|
+
query,
|
|
150
|
+
hitsPerPage: Math.min(limit, 50),
|
|
151
|
+
filters: filterParts.join(' AND '),
|
|
152
|
+
attributesToRetrieve: ['hierarchy', 'url', 'content', 'library'],
|
|
153
|
+
attributesToSnippet: ['content:80'],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const response = await fetch(
|
|
159
|
+
`https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/*/queries`,
|
|
160
|
+
{
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
'X-Algolia-Application-Id': ALGOLIA_APP_ID,
|
|
165
|
+
'X-Algolia-API-Key': ALGOLIA_API_KEY,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(searchParams),
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(`Algolia search failed: ${response.statusText}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const searchResponse = (await response.json()) as {
|
|
176
|
+
results: Array<{
|
|
177
|
+
hits: Array<{
|
|
178
|
+
objectID: string
|
|
179
|
+
url: string
|
|
180
|
+
library?: string
|
|
181
|
+
hierarchy: Record<string, string | undefined>
|
|
182
|
+
content?: string
|
|
183
|
+
_snippetResult?: { content?: { value?: string } }
|
|
184
|
+
}>
|
|
185
|
+
nbHits?: number
|
|
186
|
+
}>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const searchResult = searchResponse.results[0]
|
|
190
|
+
|
|
191
|
+
const results = searchResult.hits.map((hit) => {
|
|
192
|
+
const breadcrumb = Object.values(hit.hierarchy).filter(
|
|
193
|
+
(v): v is string => Boolean(v),
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
title: hit.hierarchy.lvl1 || hit.hierarchy.lvl0 || 'Untitled',
|
|
197
|
+
url: hit.url,
|
|
198
|
+
snippet: hit._snippetResult?.content?.value || hit.content || '',
|
|
199
|
+
library: hit.library || 'unknown',
|
|
200
|
+
breadcrumb,
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
query,
|
|
206
|
+
totalHits: searchResult.nbHits || results.length,
|
|
207
|
+
results,
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/options.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
finalizeAddOns,
|
|
5
5
|
getFrameworkById,
|
|
6
6
|
getPackageManager,
|
|
7
|
+
loadStarter,
|
|
7
8
|
populateAddOnOptionsDefaults,
|
|
8
9
|
readConfigFile,
|
|
9
10
|
} from '@tanstack/create'
|
|
@@ -17,8 +18,13 @@ import {
|
|
|
17
18
|
selectExamples,
|
|
18
19
|
selectGit,
|
|
19
20
|
selectPackageManager,
|
|
21
|
+
selectTemplate,
|
|
20
22
|
selectToolchain,
|
|
21
23
|
} from './ui-prompts.js'
|
|
24
|
+
import {
|
|
25
|
+
listTemplateChoices,
|
|
26
|
+
resolveStarterSpecifier,
|
|
27
|
+
} from './command-line.js'
|
|
22
28
|
|
|
23
29
|
import {
|
|
24
30
|
getCurrentDirectoryName,
|
|
@@ -72,6 +78,39 @@ export async function promptForCreateOptions(
|
|
|
72
78
|
!!cliOptions.routerOnly ||
|
|
73
79
|
(isLegacyTemplate ? template !== 'file-router' : false)
|
|
74
80
|
|
|
81
|
+
if (!cliOptions.starter) {
|
|
82
|
+
if (cliOptions.template && !isLegacyTemplate) {
|
|
83
|
+
cliOptions.starter = cliOptions.template
|
|
84
|
+
} else if (cliOptions.templateId) {
|
|
85
|
+
cliOptions.starter = cliOptions.templateId
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!routerOnly && !cliOptions.starter) {
|
|
90
|
+
const starterChoices = await listTemplateChoices(options.framework.id)
|
|
91
|
+
const selectedTemplateId = await selectTemplate(
|
|
92
|
+
starterChoices.map((choice) => ({
|
|
93
|
+
id: choice.id,
|
|
94
|
+
name: choice.name,
|
|
95
|
+
description: choice.description,
|
|
96
|
+
})),
|
|
97
|
+
)
|
|
98
|
+
if (selectedTemplateId) {
|
|
99
|
+
cliOptions.starter = selectedTemplateId
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const starter = !routerOnly && cliOptions.starter
|
|
104
|
+
? await loadStarter(
|
|
105
|
+
await resolveStarterSpecifier(cliOptions.starter, options.framework.id),
|
|
106
|
+
)
|
|
107
|
+
: undefined
|
|
108
|
+
|
|
109
|
+
if (starter) {
|
|
110
|
+
options.framework = getFrameworkById(starter.framework) || options.framework
|
|
111
|
+
options.mode = starter.mode
|
|
112
|
+
}
|
|
113
|
+
|
|
75
114
|
// TypeScript is always enabled with file-router
|
|
76
115
|
options.typescript = true
|
|
77
116
|
|
|
@@ -114,6 +153,9 @@ export async function promptForCreateOptions(
|
|
|
114
153
|
}
|
|
115
154
|
|
|
116
155
|
if (!routerOnly) {
|
|
156
|
+
for (const addOn of starter?.dependsOn || []) {
|
|
157
|
+
addOns.add(addOn)
|
|
158
|
+
}
|
|
117
159
|
for (const addOn of forcedAddOns) {
|
|
118
160
|
addOns.add(addOn)
|
|
119
161
|
}
|
|
@@ -188,6 +230,10 @@ export async function promptForCreateOptions(
|
|
|
188
230
|
options.install = false
|
|
189
231
|
}
|
|
190
232
|
|
|
233
|
+
if (starter) {
|
|
234
|
+
options.starter = starter
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
return options
|
|
192
238
|
}
|
|
193
239
|
|
package/src/types.ts
CHANGED
package/src/ui-prompts.ts
CHANGED
|
@@ -60,6 +60,38 @@ export async function selectPackageManager(): Promise<PackageManager> {
|
|
|
60
60
|
return packageManager
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export async function selectTemplate(
|
|
64
|
+
templates: Array<{ id: string; name: string; description?: string }>,
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
if (templates.length === 0) {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const selected = await select({
|
|
71
|
+
message: 'Would you like to start from a template?',
|
|
72
|
+
options: [
|
|
73
|
+
{
|
|
74
|
+
value: undefined,
|
|
75
|
+
label: 'None (base starter)',
|
|
76
|
+
hint: 'Two-page baseline (Home + About)',
|
|
77
|
+
},
|
|
78
|
+
...templates.map((template) => ({
|
|
79
|
+
value: template.id,
|
|
80
|
+
label: template.name,
|
|
81
|
+
hint: template.description,
|
|
82
|
+
})),
|
|
83
|
+
],
|
|
84
|
+
initialValue: undefined,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (isCancel(selected)) {
|
|
88
|
+
cancel('Operation cancelled.')
|
|
89
|
+
process.exit(0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return selected
|
|
93
|
+
}
|
|
94
|
+
|
|
63
95
|
// Track if we've shown the multiselect help text
|
|
64
96
|
let hasShownMultiselectHelp = false
|
|
65
97
|
|
|
@@ -340,6 +340,87 @@ describe('normalizeOptions', () => {
|
|
|
340
340
|
}
|
|
341
341
|
})
|
|
342
342
|
|
|
343
|
+
it('prefers framework-matching template ids from registry', async () => {
|
|
344
|
+
__testRegisterFramework({
|
|
345
|
+
id: 'react',
|
|
346
|
+
name: 'React',
|
|
347
|
+
getAddOns: () => [],
|
|
348
|
+
supportedModes: {
|
|
349
|
+
'file-router': {
|
|
350
|
+
displayName: 'File Router',
|
|
351
|
+
description: 'TanStack Router using files to define the routes',
|
|
352
|
+
forceTypescript: true,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
})
|
|
356
|
+
__testRegisterFramework({
|
|
357
|
+
id: 'solid',
|
|
358
|
+
name: 'Solid',
|
|
359
|
+
getAddOns: () => [],
|
|
360
|
+
supportedModes: {
|
|
361
|
+
'file-router': {
|
|
362
|
+
displayName: 'File Router',
|
|
363
|
+
description: 'TanStack Router using files to define the routes',
|
|
364
|
+
forceTypescript: true,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const originalRegistry = process.env.CTA_REGISTRY
|
|
370
|
+
process.env.CTA_REGISTRY = 'https://registry.example/registry.json'
|
|
371
|
+
|
|
372
|
+
fetch
|
|
373
|
+
.mockResponseOnce(
|
|
374
|
+
JSON.stringify({
|
|
375
|
+
templates: [
|
|
376
|
+
{
|
|
377
|
+
name: 'Blog',
|
|
378
|
+
description: 'React blog template',
|
|
379
|
+
url: './react/blog/template.json',
|
|
380
|
+
mode: 'file-router',
|
|
381
|
+
framework: 'react',
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: 'Blog',
|
|
385
|
+
description: 'Solid blog template',
|
|
386
|
+
url: './solid/blog/template.json',
|
|
387
|
+
mode: 'file-router',
|
|
388
|
+
framework: 'solid',
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
.mockResponseOnce(
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
id: 'blog',
|
|
396
|
+
typescript: true,
|
|
397
|
+
framework: 'solid',
|
|
398
|
+
mode: 'file-router',
|
|
399
|
+
type: 'starter',
|
|
400
|
+
description: 'Solid blog template',
|
|
401
|
+
name: 'Blog',
|
|
402
|
+
dependsOn: [],
|
|
403
|
+
files: {},
|
|
404
|
+
deletedFiles: [],
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const options = await normalizeOptions({
|
|
410
|
+
projectName: 'test',
|
|
411
|
+
framework: 'solid',
|
|
412
|
+
template: 'blog',
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
expect(options?.framework?.id).toBe('solid')
|
|
416
|
+
expect(options?.starter?.id).toBe(
|
|
417
|
+
'https://registry.example/solid/blog/template.json',
|
|
418
|
+
)
|
|
419
|
+
} finally {
|
|
420
|
+
process.env.CTA_REGISTRY = originalRegistry
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
343
424
|
it('should default to react if no framework is provided', async () => {
|
|
344
425
|
__testRegisterFramework({
|
|
345
426
|
id: 'react',
|
package/tests/options.test.ts
CHANGED
|
@@ -5,14 +5,17 @@ import {
|
|
|
5
5
|
__testClearFrameworks,
|
|
6
6
|
__testRegisterFramework,
|
|
7
7
|
} from '@tanstack/create'
|
|
8
|
+
import * as create from '@tanstack/create'
|
|
8
9
|
|
|
9
10
|
import * as prompts from '../src/ui-prompts'
|
|
11
|
+
import * as commandLine from '../src/command-line'
|
|
10
12
|
|
|
11
13
|
import type { Framework } from '@tanstack/create'
|
|
12
14
|
|
|
13
15
|
import type { CliOptions } from '../src/types'
|
|
14
16
|
|
|
15
17
|
vi.mock('../src/ui-prompts')
|
|
18
|
+
vi.mock('../src/command-line')
|
|
16
19
|
|
|
17
20
|
beforeEach(() => {
|
|
18
21
|
__testClearFrameworks()
|
|
@@ -61,7 +64,31 @@ const baseCliOptions: CliOptions = {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
function setBasicSpies() {
|
|
67
|
+
vi.spyOn(commandLine, 'listTemplateChoices').mockImplementation(async () => [])
|
|
68
|
+
vi
|
|
69
|
+
.spyOn(commandLine, 'resolveStarterSpecifier')
|
|
70
|
+
.mockImplementation(async (value) =>
|
|
71
|
+
value === 'blog'
|
|
72
|
+
? 'https://example.com/react/blog/starter.json'
|
|
73
|
+
: value,
|
|
74
|
+
)
|
|
75
|
+
vi.spyOn(create, 'loadStarter').mockImplementation(
|
|
76
|
+
async (id) =>
|
|
77
|
+
({
|
|
78
|
+
id: String(id),
|
|
79
|
+
name: 'Blog',
|
|
80
|
+
description: 'Blog template',
|
|
81
|
+
type: 'starter',
|
|
82
|
+
framework: 'react',
|
|
83
|
+
mode: 'file-router',
|
|
84
|
+
typescript: true,
|
|
85
|
+
dependsOn: [],
|
|
86
|
+
files: {},
|
|
87
|
+
deletedFiles: [],
|
|
88
|
+
}) as any,
|
|
89
|
+
)
|
|
64
90
|
vi.spyOn(prompts, 'getProjectName').mockImplementation(async () => 'hello')
|
|
91
|
+
vi.spyOn(prompts, 'selectTemplate').mockImplementation(async () => undefined)
|
|
65
92
|
vi.spyOn(prompts, 'selectPackageManager').mockImplementation(
|
|
66
93
|
async () => 'npm',
|
|
67
94
|
)
|
|
@@ -111,6 +138,44 @@ describe('promptForCreateOptions', () => {
|
|
|
111
138
|
expect(options?.tailwind).toBe(true)
|
|
112
139
|
})
|
|
113
140
|
|
|
141
|
+
it('prompts for templates when none was provided', async () => {
|
|
142
|
+
setBasicSpies()
|
|
143
|
+
vi.spyOn(commandLine, 'listTemplateChoices').mockImplementation(async () => [
|
|
144
|
+
{
|
|
145
|
+
id: 'blog',
|
|
146
|
+
name: 'Blog',
|
|
147
|
+
description: 'Blog template',
|
|
148
|
+
framework: 'react',
|
|
149
|
+
},
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
await promptForCreateOptions(baseCliOptions, {})
|
|
153
|
+
|
|
154
|
+
expect(prompts.selectTemplate).toHaveBeenCalledWith([
|
|
155
|
+
{
|
|
156
|
+
id: 'blog',
|
|
157
|
+
name: 'Blog',
|
|
158
|
+
description: 'Blog template',
|
|
159
|
+
},
|
|
160
|
+
])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('skips template prompt when template was provided via CLI', async () => {
|
|
164
|
+
setBasicSpies()
|
|
165
|
+
|
|
166
|
+
await promptForCreateOptions({ ...baseCliOptions, template: 'blog' }, {})
|
|
167
|
+
|
|
168
|
+
expect(prompts.selectTemplate).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('skips template prompt in router-only mode', async () => {
|
|
172
|
+
setBasicSpies()
|
|
173
|
+
|
|
174
|
+
await promptForCreateOptions({ ...baseCliOptions, routerOnly: true }, {})
|
|
175
|
+
|
|
176
|
+
expect(prompts.selectTemplate).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
114
179
|
//// Package manager
|
|
115
180
|
|
|
116
181
|
it('uses the package manager from the cli options', async () => {
|
package/tests/ui-prompts.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
selectAddOns,
|
|
8
8
|
selectGit,
|
|
9
9
|
selectPackageManager,
|
|
10
|
+
selectTemplate,
|
|
10
11
|
selectToolchain,
|
|
11
12
|
} from '../src/ui-prompts'
|
|
12
13
|
|
|
@@ -54,6 +55,33 @@ describe('selectPackageManager', () => {
|
|
|
54
55
|
})
|
|
55
56
|
})
|
|
56
57
|
|
|
58
|
+
describe('selectTemplate', () => {
|
|
59
|
+
it('should select a template id', async () => {
|
|
60
|
+
vi.spyOn(clack, 'select').mockImplementation(async () => 'blog')
|
|
61
|
+
vi.spyOn(clack, 'isCancel').mockImplementation(() => false)
|
|
62
|
+
|
|
63
|
+
const selectedTemplate = await selectTemplate([
|
|
64
|
+
{ id: 'blog', name: 'Blog', description: 'A blog template' },
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
expect(selectedTemplate).toBe('blog')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should return undefined when no templates are available', async () => {
|
|
71
|
+
const selectedTemplate = await selectTemplate([])
|
|
72
|
+
expect(selectedTemplate).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should exit on cancel', async () => {
|
|
76
|
+
vi.spyOn(clack, 'select').mockImplementation(async () => Symbol.for('cancel'))
|
|
77
|
+
vi.spyOn(clack, 'isCancel').mockImplementation(() => true)
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
selectTemplate([{ id: 'blog', name: 'Blog' }]),
|
|
81
|
+
).rejects.toThrowError(/exit/)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
57
85
|
describe('selectAddOns', () => {
|
|
58
86
|
it('should show keyboard shortcuts help and select add-ons', async () => {
|
|
59
87
|
const noteSpy = vi.spyOn(clack, 'note').mockImplementation(() => {})
|
|
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'
|
|
|
2
2
|
|
|
3
3
|
import { attachRuntimeGuards, createReactAppFixture } from './helpers'
|
|
4
4
|
|
|
5
|
-
test('@blocking creates a React app and navigates core
|
|
5
|
+
test('@blocking creates a React app and navigates core starter routes', async ({ page }) => {
|
|
6
6
|
const fixture = await createReactAppFixture({
|
|
7
7
|
appName: 'react-smoke-app',
|
|
8
8
|
})
|
|
@@ -12,21 +12,21 @@ test('@blocking creates a React app and navigates core demo routes', async ({ pa
|
|
|
12
12
|
await page.goto(fixture.url)
|
|
13
13
|
await expect(
|
|
14
14
|
page.getByRole('heading', {
|
|
15
|
-
name: '
|
|
15
|
+
name: 'Start simple, ship quickly.',
|
|
16
16
|
}),
|
|
17
17
|
).toBeVisible()
|
|
18
18
|
|
|
19
|
-
await page.getByRole('link', { name: '
|
|
20
|
-
await expect(page).toHaveURL(/\/blog\/?$/)
|
|
21
|
-
await expect(page.getByRole('heading', { name: 'Blog' })).toBeVisible()
|
|
22
|
-
|
|
23
|
-
await page.locator('main article a').first().click()
|
|
24
|
-
await expect(page).toHaveURL(/\/blog\/.+/)
|
|
25
|
-
await expect(page.getByText('Post', { exact: true })).toBeVisible()
|
|
26
|
-
|
|
27
|
-
await page.getByRole('link', { name: 'About' }).click()
|
|
19
|
+
await page.getByRole('link', { name: 'About', exact: true }).click()
|
|
28
20
|
await expect(page).toHaveURL(/\/about\/?$/)
|
|
29
|
-
await expect(
|
|
21
|
+
await expect(
|
|
22
|
+
page.getByRole('heading', { name: 'A small starter with room to grow.' }),
|
|
23
|
+
).toBeVisible()
|
|
24
|
+
|
|
25
|
+
await page.getByRole('link', { name: 'Home' }).click()
|
|
26
|
+
await expect(page).toHaveURL(/\/?$/)
|
|
27
|
+
await expect(
|
|
28
|
+
page.getByRole('heading', { name: 'Start simple, ship quickly.' }),
|
|
29
|
+
).toBeVisible()
|
|
30
30
|
} finally {
|
|
31
31
|
try {
|
|
32
32
|
guards.assertClean()
|
|
@@ -14,7 +14,7 @@ test('@blocking creates a React router-only app and navigates every internal lin
|
|
|
14
14
|
try {
|
|
15
15
|
await page.goto(fixture.url)
|
|
16
16
|
await expect(
|
|
17
|
-
page.getByRole('heading', { name: '
|
|
17
|
+
page.getByRole('heading', { name: 'Start simple, ship quickly.' }),
|
|
18
18
|
).toBeVisible()
|
|
19
19
|
|
|
20
20
|
const homeLinks = await page
|
|
@@ -25,36 +25,22 @@ test('@blocking creates a React router-only app and navigates every internal lin
|
|
|
25
25
|
.sort(),
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
expect(homeLinks).toContain('/
|
|
28
|
+
expect(homeLinks).toContain('/about')
|
|
29
29
|
|
|
30
|
-
await page.locator('a[href="/
|
|
31
|
-
await expect(page).toHaveURL(/\/
|
|
32
|
-
await expect(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.locator('a[href^="/blog/"]')
|
|
36
|
-
.evaluateAll((anchors) =>
|
|
37
|
-
Array.from(new Set(anchors.map((anchor) => anchor.getAttribute('href') || '')))
|
|
38
|
-
.filter(Boolean)
|
|
39
|
-
.sort(),
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
expect(blogPostLinks.length).toBeGreaterThan(0)
|
|
43
|
-
|
|
44
|
-
for (const postPath of blogPostLinks) {
|
|
45
|
-
await page.locator(`a[href="${postPath}"]`).first().click()
|
|
46
|
-
await expect(page).toHaveURL(new RegExp(`${postPath}/?$`))
|
|
47
|
-
await expect(page.locator('h1').first()).toBeVisible()
|
|
48
|
-
await page.goBack()
|
|
49
|
-
await expect(page).toHaveURL(/\/blog\/?$/)
|
|
50
|
-
}
|
|
30
|
+
await page.locator('a[href="/about"]').first().click()
|
|
31
|
+
await expect(page).toHaveURL(/\/about\/?$/)
|
|
32
|
+
await expect(
|
|
33
|
+
page.getByRole('heading', { name: 'A small starter with room to grow.' }),
|
|
34
|
+
).toBeVisible()
|
|
51
35
|
|
|
52
36
|
await page.goto(`${fixture.url}/about`)
|
|
53
|
-
await expect(
|
|
37
|
+
await expect(
|
|
38
|
+
page.getByRole('heading', { name: 'A small starter with room to grow.' }),
|
|
39
|
+
).toBeVisible()
|
|
54
40
|
|
|
55
41
|
await page.goto(fixture.url)
|
|
56
42
|
await expect(
|
|
57
|
-
page.getByRole('heading', { name: '
|
|
43
|
+
page.getByRole('heading', { name: 'Start simple, ship quickly.' }),
|
|
58
44
|
).toBeVisible()
|
|
59
45
|
} finally {
|
|
60
46
|
try {
|
|
@@ -11,8 +11,9 @@ test('@blocking creates a Solid app and renders the home route', async ({ page }
|
|
|
11
11
|
|
|
12
12
|
try {
|
|
13
13
|
await page.goto(fixture.url)
|
|
14
|
-
await expect(
|
|
15
|
-
|
|
14
|
+
await expect(
|
|
15
|
+
page.getByRole('heading', { name: 'Start simple, ship quickly.' }),
|
|
16
|
+
).toBeVisible()
|
|
16
17
|
} finally {
|
|
17
18
|
try {
|
|
18
19
|
guards.assertClean()
|