@tanstack/cli 0.59.8 → 0.60.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.
@@ -0,0 +1,526 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import type { Page } from '@playwright/test'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const repoRoot = resolve(here, '../../..')
10
+ const cliDistPath = resolve(repoRoot, 'packages/cli/dist/index.js')
11
+
12
+ export type E2EApp = {
13
+ rootDir: string
14
+ appDir: string
15
+ url: string
16
+ framework: 'react' | 'solid'
17
+ packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno'
18
+ stop: () => Promise<void>
19
+ cleanup: () => Promise<void>
20
+ }
21
+
22
+ type CreateAppFixtureOptions = {
23
+ appName: string
24
+ framework?: 'react' | 'solid'
25
+ packageManager?: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno'
26
+ routerOnly?: boolean
27
+ template?: string
28
+ addOns?: Array<string>
29
+ postCreateAddOns?: Array<string>
30
+ skipDevServer?: boolean
31
+ }
32
+
33
+ export type RuntimeGuards = {
34
+ assertClean: () => void
35
+ dispose: () => void
36
+ }
37
+
38
+ function runCommand(
39
+ command: string,
40
+ args: Array<string>,
41
+ opts: {
42
+ cwd: string
43
+ env?: NodeJS.ProcessEnv
44
+ },
45
+ ) {
46
+ return new Promise<void>((resolvePromise, rejectPromise) => {
47
+ const child = spawn(command, args, {
48
+ cwd: opts.cwd,
49
+ env: {
50
+ ...process.env,
51
+ ...opts.env,
52
+ },
53
+ stdio: 'pipe',
54
+ })
55
+
56
+ let stdout = ''
57
+ let stderr = ''
58
+
59
+ child.stdout.on('data', (chunk) => {
60
+ stdout += String(chunk)
61
+ })
62
+
63
+ child.stderr.on('data', (chunk) => {
64
+ stderr += String(chunk)
65
+ })
66
+
67
+ child.on('error', (err) => {
68
+ rejectPromise(err)
69
+ })
70
+
71
+ child.on('close', (code) => {
72
+ if (code === 0) {
73
+ resolvePromise()
74
+ return
75
+ }
76
+ rejectPromise(
77
+ new Error(
78
+ `${command} ${args.join(' ')} failed with code ${code}\n` +
79
+ `stdout:\n${stdout}\n\n` +
80
+ `stderr:\n${stderr}`,
81
+ ),
82
+ )
83
+ })
84
+ })
85
+ }
86
+
87
+ function waitForServer(url: string, timeoutMs = 90_000) {
88
+ const started = Date.now()
89
+ return new Promise<void>((resolvePromise, rejectPromise) => {
90
+ const attempt = async () => {
91
+ try {
92
+ const response = await fetch(url)
93
+ if (response.ok) {
94
+ resolvePromise()
95
+ return
96
+ }
97
+ } catch {
98
+ // keep waiting
99
+ }
100
+
101
+ if (Date.now() - started > timeoutMs) {
102
+ rejectPromise(new Error(`Timed out waiting for server: ${url}`))
103
+ return
104
+ }
105
+
106
+ setTimeout(attempt, 500)
107
+ }
108
+
109
+ void attempt()
110
+ })
111
+ }
112
+
113
+ function stripAnsi(value: string) {
114
+ return value.replace(/\u001b\[[0-9;]*m/g, '')
115
+ }
116
+
117
+ function waitForDevServerURL(getStdout: () => string, timeoutMs = 90_000) {
118
+ const started = Date.now()
119
+
120
+ return new Promise<string>((resolvePromise, rejectPromise) => {
121
+ const attempt = () => {
122
+ const stdout = stripAnsi(getStdout())
123
+ const match = stdout.match(/Local:\s+(https?:\/\/[^\s]+)/)
124
+ if (match?.[1]) {
125
+ resolvePromise(match[1].replace(/\/$/, ''))
126
+ return
127
+ }
128
+
129
+ if (Date.now() - started > timeoutMs) {
130
+ rejectPromise(new Error('Timed out waiting for dev server URL in output'))
131
+ return
132
+ }
133
+
134
+ setTimeout(attempt, 250)
135
+ }
136
+
137
+ attempt()
138
+ })
139
+ }
140
+
141
+ async function stopChild(child: ReturnType<typeof spawn>) {
142
+ if (child.killed || child.exitCode !== null) {
143
+ return
144
+ }
145
+
146
+ const killTree = (signal: NodeJS.Signals) => {
147
+ if (typeof child.pid === 'number') {
148
+ try {
149
+ process.kill(-child.pid, signal)
150
+ return
151
+ } catch {
152
+ // fall through to direct child kill
153
+ }
154
+ }
155
+ child.kill(signal)
156
+ }
157
+
158
+ killTree('SIGTERM')
159
+
160
+ await new Promise<void>((resolvePromise) => {
161
+ const timer = setTimeout(() => {
162
+ if (!child.killed && child.exitCode === null) {
163
+ killTree('SIGKILL')
164
+ }
165
+ resolvePromise()
166
+ }, 5_000)
167
+
168
+ child.once('close', () => {
169
+ clearTimeout(timer)
170
+ resolvePromise()
171
+ })
172
+ })
173
+ }
174
+
175
+ async function patchViteConfigForE2E(appDir: string) {
176
+ const viteConfigPath = join(appDir, 'vite.config.ts')
177
+
178
+ let viteConfig = ''
179
+ try {
180
+ viteConfig = await readFile(viteConfigPath, 'utf8')
181
+ } catch {
182
+ return
183
+ }
184
+
185
+ const next = viteConfig.replace(
186
+ 'devtools(),',
187
+ 'devtools({ eventBusConfig: { enabled: false } }),',
188
+ )
189
+
190
+ if (next !== viteConfig) {
191
+ await writeFile(viteConfigPath, next)
192
+ }
193
+ }
194
+
195
+ export function getRepoPath(...segments: Array<string>) {
196
+ return resolve(repoRoot, ...segments)
197
+ }
198
+
199
+ export async function createReactAppFixture(
200
+ options: Omit<CreateAppFixtureOptions, 'framework'>,
201
+ ): Promise<E2EApp> {
202
+ return createAppFixture({
203
+ framework: 'react',
204
+ ...options,
205
+ })
206
+ }
207
+
208
+ function getPackageManagerCommandForScript(
209
+ packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno',
210
+ script: 'dev' | 'build',
211
+ ) {
212
+ switch (packageManager) {
213
+ case 'npm':
214
+ return {
215
+ command: 'npm',
216
+ args: ['run', script],
217
+ }
218
+ case 'pnpm':
219
+ return {
220
+ command: 'pnpm',
221
+ args: [script],
222
+ }
223
+ case 'yarn':
224
+ return {
225
+ command: 'yarn',
226
+ args: [script],
227
+ }
228
+ case 'bun':
229
+ return {
230
+ command: 'bun',
231
+ args: ['run', script],
232
+ }
233
+ case 'deno':
234
+ return {
235
+ command: 'deno',
236
+ args: ['task', script],
237
+ }
238
+ }
239
+ }
240
+
241
+ function getPackageManagerTypecheckCommand(
242
+ packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno',
243
+ ) {
244
+ switch (packageManager) {
245
+ case 'npm':
246
+ return {
247
+ command: 'npm',
248
+ args: ['exec', '--', 'tsc', '--noEmit'],
249
+ }
250
+ case 'pnpm':
251
+ return {
252
+ command: 'pnpm',
253
+ args: ['exec', 'tsc', '--noEmit'],
254
+ }
255
+ case 'yarn':
256
+ return {
257
+ command: 'yarn',
258
+ args: ['exec', 'tsc', '--noEmit'],
259
+ }
260
+ case 'bun':
261
+ return {
262
+ command: 'bun',
263
+ args: ['x', 'tsc', '--noEmit'],
264
+ }
265
+ case 'deno':
266
+ return {
267
+ command: 'deno',
268
+ args: ['task', 'typecheck'],
269
+ }
270
+ }
271
+ }
272
+
273
+ async function runQualityGates(
274
+ appDir: string,
275
+ packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno',
276
+ ) {
277
+ const build = getPackageManagerCommandForScript(packageManager, 'build')
278
+ await runCommand(build.command, build.args, {
279
+ cwd: appDir,
280
+ env: {
281
+ CI: '1',
282
+ },
283
+ })
284
+
285
+ const typecheck = getPackageManagerTypecheckCommand(packageManager)
286
+ await runCommand(typecheck.command, typecheck.args, {
287
+ cwd: appDir,
288
+ env: {
289
+ CI: '1',
290
+ },
291
+ })
292
+ }
293
+
294
+ export async function createAppFixture(
295
+ options: CreateAppFixtureOptions,
296
+ ): Promise<E2EApp> {
297
+ await access(cliDistPath)
298
+
299
+ const rootDir = await mkdtemp(join(tmpdir(), 'tanstack-cli-e2e-'))
300
+ const {
301
+ appName,
302
+ template,
303
+ addOns,
304
+ postCreateAddOns,
305
+ skipDevServer,
306
+ framework = 'react',
307
+ packageManager = 'pnpm',
308
+ routerOnly = false,
309
+ } = options
310
+ const appDir = join(rootDir, appName)
311
+
312
+ const createArgs = [
313
+ cliDistPath,
314
+ 'create',
315
+ appName,
316
+ '--framework',
317
+ framework,
318
+ '--package-manager',
319
+ packageManager,
320
+ '--no-git',
321
+ ]
322
+
323
+ if (routerOnly) {
324
+ createArgs.push('--router-only')
325
+ }
326
+
327
+ if (template) {
328
+ createArgs.push('--template', template)
329
+ }
330
+
331
+ if (addOns?.length) {
332
+ createArgs.push('--add-ons', addOns.join(','))
333
+ }
334
+
335
+ await runCommand(
336
+ 'node',
337
+ createArgs,
338
+ {
339
+ cwd: rootDir,
340
+ env: {
341
+ CI: '1',
342
+ },
343
+ },
344
+ )
345
+
346
+ await patchViteConfigForE2E(appDir)
347
+
348
+ if (postCreateAddOns?.length) {
349
+ await runCommand('node', [cliDistPath, 'add', ...postCreateAddOns], {
350
+ cwd: appDir,
351
+ env: {
352
+ CI: '1',
353
+ },
354
+ })
355
+
356
+ await patchViteConfigForE2E(appDir)
357
+ }
358
+
359
+ await runQualityGates(appDir, packageManager)
360
+
361
+ if (skipDevServer) {
362
+ return {
363
+ rootDir,
364
+ appDir,
365
+ url: '',
366
+ framework,
367
+ packageManager,
368
+ stop: async () => {},
369
+ cleanup: async () => {
370
+ await rm(rootDir, { recursive: true, force: true })
371
+ },
372
+ }
373
+ }
374
+
375
+ const dev = getPackageManagerCommandForScript(packageManager, 'dev')
376
+
377
+ const server = spawn(dev.command, dev.args, {
378
+ cwd: appDir,
379
+ env: {
380
+ ...process.env,
381
+ CI: '1',
382
+ },
383
+ stdio: 'pipe',
384
+ detached: true,
385
+ })
386
+
387
+ let serverStdout = ''
388
+ server.stdout.on('data', (chunk) => {
389
+ serverStdout += String(chunk)
390
+ })
391
+
392
+ let serverStderr = ''
393
+ server.stderr.on('data', (chunk) => {
394
+ serverStderr += String(chunk)
395
+ })
396
+
397
+ let url = 'http://localhost:3000'
398
+ try {
399
+ url = await waitForDevServerURL(() => serverStdout)
400
+ await waitForServer(url)
401
+ } catch (error) {
402
+ await stopChild(server)
403
+ throw new Error(
404
+ `Failed to start app server at ${url}\nstdout:\n${serverStdout}\n\nstderr:\n${serverStderr}\n\n${error}`,
405
+ )
406
+ }
407
+
408
+ return {
409
+ rootDir,
410
+ appDir,
411
+ url,
412
+ framework,
413
+ packageManager,
414
+ stop: async () => {
415
+ await stopChild(server)
416
+ },
417
+ cleanup: async () => {
418
+ await rm(rootDir, { recursive: true, force: true })
419
+ },
420
+ }
421
+ }
422
+
423
+ function toSameOrigin(url: string, appOrigin: URL) {
424
+ try {
425
+ const parsed = new URL(url)
426
+ return parsed.origin === appOrigin.origin
427
+ } catch {
428
+ return false
429
+ }
430
+ }
431
+
432
+ export function attachRuntimeGuards(page: Page, appUrl: string): RuntimeGuards {
433
+ const appOrigin = new URL(appUrl)
434
+ const pageErrors: Array<string> = []
435
+ const consoleErrors: Array<string> = []
436
+ const requestFailures: Array<string> = []
437
+ const httpErrors: Array<string> = []
438
+
439
+ const onPageError = (error: Error) => {
440
+ pageErrors.push(error.message)
441
+ }
442
+
443
+ const onConsole = (message: { type: () => string; text: () => string }) => {
444
+ if (message.type() === 'error') {
445
+ consoleErrors.push(message.text())
446
+ }
447
+ }
448
+
449
+ const onRequestFailed = (request: {
450
+ method: () => string
451
+ url: () => string
452
+ failure: () => { errorText?: string } | null
453
+ }) => {
454
+ const url = request.url()
455
+ if (!toSameOrigin(url, appOrigin)) {
456
+ return
457
+ }
458
+ const errorText = request.failure()?.errorText || 'unknown error'
459
+
460
+ if (errorText.includes('ERR_ABORTED')) {
461
+ return
462
+ }
463
+
464
+ requestFailures.push(`${request.method()} ${url} :: ${errorText}`)
465
+ }
466
+
467
+ const onResponse = (response: {
468
+ url: () => string
469
+ status: () => number
470
+ request: () => { method: () => string }
471
+ }) => {
472
+ const url = response.url()
473
+ if (!toSameOrigin(url, appOrigin)) {
474
+ return
475
+ }
476
+
477
+ const status = response.status()
478
+ if (status >= 400) {
479
+ httpErrors.push(`${response.request().method()} ${status} ${url}`)
480
+ }
481
+ }
482
+
483
+ page.on('pageerror', onPageError)
484
+ page.on('console', onConsole)
485
+ page.on('requestfailed', onRequestFailed)
486
+ page.on('response', onResponse)
487
+
488
+ return {
489
+ assertClean: () => {
490
+ if (
491
+ pageErrors.length === 0 &&
492
+ consoleErrors.length === 0 &&
493
+ requestFailures.length === 0 &&
494
+ httpErrors.length === 0
495
+ ) {
496
+ return
497
+ }
498
+
499
+ throw new Error(
500
+ [
501
+ 'Runtime errors detected in browser session:',
502
+ pageErrors.length
503
+ ? `pageerror:\n${pageErrors.map((line) => `- ${line}`).join('\n')}`
504
+ : '',
505
+ consoleErrors.length
506
+ ? `console.error:\n${consoleErrors.map((line) => `- ${line}`).join('\n')}`
507
+ : '',
508
+ requestFailures.length
509
+ ? `requestfailed:\n${requestFailures.map((line) => `- ${line}`).join('\n')}`
510
+ : '',
511
+ httpErrors.length
512
+ ? `http >= 400:\n${httpErrors.map((line) => `- ${line}`).join('\n')}`
513
+ : '',
514
+ ]
515
+ .filter(Boolean)
516
+ .join('\n\n'),
517
+ )
518
+ },
519
+ dispose: () => {
520
+ page.off('pageerror', onPageError)
521
+ page.off('console', onConsole)
522
+ page.off('requestfailed', onRequestFailed)
523
+ page.off('response', onResponse)
524
+ },
525
+ }
526
+ }
@@ -0,0 +1,142 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import type { Page } from '@playwright/test'
3
+
4
+ import {
5
+ attachRuntimeGuards,
6
+ createAppFixture,
7
+ getRepoPath,
8
+ type E2EApp,
9
+ } from './helpers'
10
+
11
+ type MatrixScenario = {
12
+ id: string
13
+ framework: 'react' | 'solid'
14
+ packageManager: 'pnpm' | 'npm'
15
+ template?: string
16
+ addOns?: Array<string>
17
+ postCreateAddOns?: Array<string>
18
+ visits?: Array<string>
19
+ assert: (fixture: E2EApp, page: Page) => Promise<void>
20
+ }
21
+
22
+ const scenarios: Array<MatrixScenario> = [
23
+ {
24
+ id: 'react-base-pnpm',
25
+ framework: 'react',
26
+ packageManager: 'pnpm',
27
+ visits: ['/'],
28
+ assert: async (_, page) => {
29
+ await expect(
30
+ page.getByRole('heading', { name: 'Island hours, but for product teams.' }),
31
+ ).toBeVisible()
32
+ },
33
+ },
34
+ {
35
+ id: 'react-base-npm',
36
+ framework: 'react',
37
+ packageManager: 'npm',
38
+ visits: ['/'],
39
+ assert: async (_, page) => {
40
+ await expect(
41
+ page.getByRole('heading', { name: 'Island hours, but for product teams.' }),
42
+ ).toBeVisible()
43
+ },
44
+ },
45
+ {
46
+ id: 'solid-base-npm',
47
+ framework: 'solid',
48
+ packageManager: 'npm',
49
+ visits: ['/'],
50
+ assert: async (_, page) => {
51
+ await expect(page.getByRole('heading', { name: /TANSTACK/i })).toBeVisible()
52
+ },
53
+ },
54
+ {
55
+ id: 'react-template-resume',
56
+ framework: 'react',
57
+ packageManager: 'pnpm',
58
+ template: getRepoPath('examples/react/resume/template.json'),
59
+ visits: ['/'],
60
+ assert: async (_, page) => {
61
+ await expect(page.getByRole('heading', { name: /Hi, I'm Jane Smith\./ })).toBeVisible()
62
+ },
63
+ },
64
+ {
65
+ id: 'react-template-ecommerce',
66
+ framework: 'react',
67
+ packageManager: 'pnpm',
68
+ template: getRepoPath('examples/react/ecommerce/template.json'),
69
+ visits: ['/', '/#products'],
70
+ assert: async (_, page) => {
71
+ await expect(page.getByRole('heading', { name: 'The TanStack Storefront.' })).toBeVisible()
72
+ },
73
+ },
74
+ {
75
+ id: 'react-addons-core-pnpm',
76
+ framework: 'react',
77
+ packageManager: 'pnpm',
78
+ addOns: ['shadcn', 'form', 'tanstack-query', 'store'],
79
+ visits: ['/demo/form/simple', '/demo/tanstack-query', '/demo/store'],
80
+ assert: async (_, page) => {
81
+ await expect(page.getByRole('heading', { name: 'Store Example' })).toBeVisible()
82
+ },
83
+ },
84
+ {
85
+ id: 'solid-addons-core-pnpm',
86
+ framework: 'solid',
87
+ packageManager: 'pnpm',
88
+ visits: ['/'],
89
+ assert: async (_, page) => {
90
+ await expect(page.getByRole('heading', { name: /TANSTACK/i })).toBeVisible()
91
+ },
92
+ },
93
+ {
94
+ id: 'react-toolchain-deploy',
95
+ framework: 'react',
96
+ packageManager: 'pnpm',
97
+ addOns: ['biome', 'netlify'],
98
+ visits: ['/'],
99
+ assert: async (_, page) => {
100
+ await expect(
101
+ page.getByRole('heading', { name: 'Island hours, but for product teams.' }),
102
+ ).toBeVisible()
103
+ },
104
+ },
105
+ ]
106
+
107
+ const selectedScenarioId = process.env.E2E_MATRIX_SCENARIO
108
+ const selectedScenarios = selectedScenarioId
109
+ ? scenarios.filter((scenario) => scenario.id === selectedScenarioId)
110
+ : scenarios
111
+
112
+ test.describe('@matrix opportunistic matrix', () => {
113
+ for (const scenario of selectedScenarios) {
114
+ test(`@matrix ${scenario.id}`, async ({ page }) => {
115
+ const fixture = await createAppFixture({
116
+ appName: `${scenario.id}-smoke`,
117
+ framework: scenario.framework,
118
+ packageManager: scenario.packageManager,
119
+ template: scenario.template,
120
+ addOns: scenario.addOns,
121
+ postCreateAddOns: scenario.postCreateAddOns,
122
+ })
123
+ const guards = attachRuntimeGuards(page, fixture.url)
124
+
125
+ try {
126
+ for (const visit of scenario.visits || ['/']) {
127
+ await page.goto(`${fixture.url}${visit}`)
128
+ await expect(page.locator('body')).toBeVisible()
129
+ }
130
+ await scenario.assert(fixture, page)
131
+ } finally {
132
+ try {
133
+ guards.assertClean()
134
+ } finally {
135
+ guards.dispose()
136
+ await fixture.stop()
137
+ await fixture.cleanup()
138
+ }
139
+ }
140
+ })
141
+ }
142
+ })