Files
Tim Neutkens c443050093 Use Tailwind Turbopack loader in create-next-app (#96606)
## Summary

Use `@tailwindcss/turbopack` and a Turbopack CSS loader rule for
Tailwind projects generated with Turbopack, avoiding the PostCSS setup
in the default path.

Keep `@tailwindcss/postcss` and `postcss.config.mjs` for non-Turbopack
bundlers, and update create-next-app production coverage to verify
generated configuration, dependencies, and rendered Tailwind styles.

## Verification

- `pnpm test-start-turbo
test/production/create-next-app/templates/app.test.ts -t "should create
TailwindCSS project with --tailwind flag"`
- `pnpm test-start-rspack
test/production/create-next-app/templates/app.test.ts -t "should create
TailwindCSS project with --tailwind flag"`

<!-- NEXT_JS_LLM -->
2026-08-05 15:49:17 +02:00

220 lines
5.3 KiB
TypeScript

/**
* @fileoverview
*
* This file contains utilities for `create-next-app` testing.
*/
import { execSync, spawn, SpawnOptions } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { join, resolve } from 'path'
import glob from 'glob'
import Conf from 'next/dist/compiled/conf'
import {
getProjectSetting,
mapSrcFiles,
projectSpecification,
} from './specification'
import {
CustomTemplateOptions,
DefaultTemplateOptions,
ProjectDeps,
ProjectFiles,
} from './types'
import { serializeTestPkgPathsEnv } from './test-pkg-paths'
const cli = require.resolve('create-next-app/dist/index.js')
/**
* Run the built version of `create-next-app` with the given arguments.
*/
export const createNextApp = (
args: string[],
options?: SpawnOptions,
testVersion?: string,
clearPreferences: boolean = true
) => {
const conf = new Conf({ projectName: 'create-next-app' })
if (clearPreferences) {
conf.clear()
}
console.log(`[TEST] $ ${cli} ${args.join(' ')}`, { options })
const cloneEnv = { ...process.env }
// unset CI env as this skips the auto-install behavior
// being tested
delete cloneEnv.CI
delete cloneEnv.CIRCLECI
delete cloneEnv.GITHUB_ACTIONS
delete cloneEnv.CONTINUOUS_INTEGRATION
delete cloneEnv.RUN_ID
delete cloneEnv.BUILD_NUMBER
cloneEnv.NEXT_PRIVATE_TEST_VERSION = testVersion || 'canary'
// Forward all packed workspace tarballs so CNA can install siblings
// (`next-rspack`, `eslint-config-next`, ...) from their own tarballs
// when running tests directly (without `run-tests.js`).
const pkgPathsEnv = serializeTestPkgPathsEnv()
if (pkgPathsEnv) {
cloneEnv.NEXT_TEST_PKG_PATHS = pkgPathsEnv
}
return spawn('node', [cli].concat(args), {
...options,
env: {
...cloneEnv,
...options.env,
},
})
}
export const projectShouldHaveNoGitChanges = ({
cwd,
projectName,
}: DefaultTemplateOptions) => {
const projectDirname = join(cwd, projectName)
try {
execSync('git diff --quiet', { cwd: projectDirname })
} catch {
execSync('git status', { cwd: projectDirname, stdio: 'inherit' })
execSync('git --no-pager diff', { cwd: projectDirname, stdio: 'inherit' })
throw new Error('Found unexpected git changes.')
}
}
export const projectFilesShouldExist = ({
cwd,
projectName,
files,
}: ProjectFiles) => {
const projectRoot = resolve(cwd, projectName)
for (const file of files) {
try {
expect(existsSync(resolve(projectRoot, file))).toBe(true)
} catch (err) {
require('console').error(
`missing expected file ${file}`,
glob.sync('**/*', { cwd, ignore: '**/node_modules/**' }),
files
)
throw err
}
}
}
export const projectFilesShouldNotExist = ({
cwd,
projectName,
files,
}: ProjectFiles) => {
const projectRoot = resolve(cwd, projectName)
for (const file of files) {
try {
expect(existsSync(resolve(projectRoot, file))).toBe(false)
} catch (err) {
require('console').error(
`unexpected file present ${file}`,
glob.sync('**/*', { cwd, ignore: '**/node_modules/**' }),
files
)
throw err
}
}
}
export const projectDepsShouldBe = ({
cwd,
projectName,
type,
deps,
}: ProjectDeps) => {
const projectRoot = resolve(cwd, projectName)
const pkgJson = require(resolve(projectRoot, 'package.json'))
expect(Object.keys(pkgJson[type] || {}).sort()).toEqual(deps.sort())
}
export const shouldBeTemplateProject = ({
cwd,
projectName,
template,
mode,
srcDir,
}: CustomTemplateOptions) => {
const isTailwindTemplate = [
'app-tw',
'app-tw-empty',
'default-tw',
'default-tw-empty',
].includes(template)
projectFilesShouldExist({
cwd,
projectName,
files: getProjectSetting({ template, mode, setting: 'files', srcDir }),
})
// Tailwind templates share the same files across JavaScript and TypeScript.
if (!isTailwindTemplate) {
projectFilesShouldNotExist({
cwd,
projectName,
files: mapSrcFiles(
projectSpecification[template][mode === 'js' ? 'ts' : 'js'].files,
srcDir
),
})
}
const projectRoot = resolve(cwd, projectName)
const nextConfig = readFileSync(
resolve(projectRoot, mode === 'js' ? 'next.config.mjs' : 'next.config.ts'),
'utf8'
)
if (isTailwindTemplate && !process.env.NEXT_RSPACK) {
expect(nextConfig).toContain('loaders: ["@tailwindcss/turbopack"]')
projectFilesShouldNotExist({
cwd,
projectName,
files: ['postcss.config.mjs'],
})
} else {
expect(nextConfig).not.toContain('@tailwindcss/turbopack')
}
projectDepsShouldBe({
type: 'dependencies',
cwd,
projectName,
deps: getProjectSetting({ template, mode, setting: 'deps' }),
})
projectDepsShouldBe({
type: 'devDependencies',
cwd,
projectName,
deps: getProjectSetting({ template, mode, setting: 'devDeps' }),
})
}
export const shouldBeJavascriptProject = ({
cwd,
projectName,
template,
srcDir,
}: Omit<CustomTemplateOptions, 'mode'>) => {
shouldBeTemplateProject({ cwd, projectName, template, mode: 'js', srcDir })
}
export const shouldBeTypescriptProject = ({
cwd,
projectName,
template,
srcDir,
}: Omit<CustomTemplateOptions, 'mode'>) => {
shouldBeTemplateProject({ cwd, projectName, template, mode: 'ts', srcDir })
}