@tanstack/cli 0.59.7 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/bin.js +5 -0
- package/dist/cli.js +118 -93
- package/dist/command-line.js +143 -8
- package/dist/dev-watch.js +117 -16
- package/dist/file-syncer.js +30 -1
- package/dist/index.js +15 -1
- package/dist/options.js +5 -2
- package/dist/types/cli.d.ts +1 -2
- package/dist/types/dev-watch.d.ts +6 -0
- package/dist/types/file-syncer.d.ts +8 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types.d.ts +2 -1
- package/package.json +8 -3
- package/playwright-report/index.html +85 -0
- package/playwright.config.ts +21 -0
- package/src/bin.ts +8 -0
- package/src/cli.ts +150 -119
- package/src/command-line.ts +193 -7
- package/src/dev-watch.ts +163 -29
- package/src/file-syncer.ts +59 -1
- package/src/index.ts +21 -1
- package/src/options.ts +8 -2
- package/src/types.ts +2 -1
- package/test-results/.last-run.json +4 -0
- package/tests/command-line.test.ts +203 -15
- package/tests/options.test.ts +2 -2
- package/tests-e2e/addons-smoke.spec.ts +31 -0
- package/tests-e2e/create-smoke.spec.ts +39 -0
- package/tests-e2e/helpers.ts +526 -0
- package/tests-e2e/matrix-opportunistic.spec.ts +142 -0
- package/tests-e2e/router-only-smoke.spec.ts +68 -0
- package/tests-e2e/solid-smoke.spec.ts +25 -0
- package/tests-e2e/templates-smoke.spec.ts +52 -0
- package/vitest.config.js +1 -0
|
@@ -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
|
+
})
|