Add Next.js integration test (#14163)

Using the [new integration test
setup](https://github.com/tailwindlabs/tailwindcss/pull/14089), this PR
adds a test for a V4 Next.js setup using the Postcss plugin. It's
testing both a full build and the dev mode (non-turbo for now).

Because of webpack, tests are quite slow which is worrisome since we
probably need to add many more integrations in the future. One idea I
have is that we separate tests in two buckets: _essential_ tests that
run often and are fast and advanced suites that we only run on CI via
custom, non-blocking, jobs.

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Philipp Spiess 2024-08-16 15:45:52 +02:00 • committed by GitHub
parent a902128640
commit e10b786437
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 39 deletions

View file

@ -0,0 +1,154 @@
import { expect } from 'vitest'
import { candidate, css, fetchStylesFromIndex, js, json, retryAssertion, test } from '../utils'
test(
'production build',
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
`,
'next.config.mjs': js`
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig
`,
'app/layout.js': js`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
export default function Page() {
return <h1 className="text-3xl font-bold underline">Hello, Next.js!</h1>
}
`,
'app/globals.css': css`
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm next build')
let files = await fs.glob('.next/static/css/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [
candidate`underline`,
candidate`font-bold`,
candidate`text-3xl`,
])
},
)
;['turbo', 'webpack'].forEach((bundler) => {
test(
`dev mode (${bundler})`,
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
`,
'next.config.mjs': js`
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig
`,
'app/layout.js': js`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
export default function Page() {
return <h1 className="underline">Hello, Next.js!</h1>
}
`,
'app/globals.css': css`
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
`,
},
},
async ({ fs, spawn, getFreePort }) => {
let port = await getFreePort()
await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''} --port ${port}`)
await retryAssertion(async () => {
let css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`underline`)
})
await fs.write(
'app/page.js',
js`
export default function Page() {
return <h1 className="underline text-red-500">Hello, Next.js!</h1>
}
`,
)
await retryAssertion(async () => {
let css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`text-red-500`)
})
},
)
})

View file

@ -48,8 +48,14 @@ interface TestFlags {
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
const TEST_TIMEOUT = 30000
const ASSERTION_TIMEOUT = 5000
const IS_WINDOWS = platform() === 'win32'
const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
// On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder that
// apparently causes the vite builds to not work.
const TMP_ROOT = process.env.CI && IS_WINDOWS ? homedir() : tmpdir()
export function test(
name: string,
@ -57,17 +63,11 @@ export function test(
testCallback: TestCallback,
{ only = false, debug = false }: TestFlags = {},
) {
return (only ? defaultTest.only : defaultTest)(
return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)(
name,
{ timeout: TEST_TIMEOUT },
async (options) => {
let rootDir = debug
? path.join(REPO_ROOT, '.debug')
: // On Windows CI, tmpdir returns a path containing a weird RUNNER~1
// folder that apparently causes the vite builds to not work.
process.env.CI && platform() === 'win32'
? homedir()
: tmpdir()
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
await fs.mkdir(rootDir, { recursive: true })
let root = await fs.mkdtemp(path.join(rootDir, 'tailwind-integrations'))
@ -181,7 +181,7 @@ export function test(
})
options.onTestFailed(() => {
// In debug mode, messages are logged to the console immediatly
// In debug mode, messages are logged to the console immediately
if (debug) return
for (let [type, message] of combined) {
@ -233,7 +233,11 @@ export function test(
return
}
await killPort(port)
try {
await killPort(port)
} catch {
// If the process can not be killed, we can't do anything
}
})
resolve(port)
}
@ -250,7 +254,7 @@ export function test(
}
// Ensure that files written on Windows use \r\n line ending
if (platform() === 'win32') {
if (IS_WINDOWS) {
content = content.replace(/\n/g, '\r\n')
}
@ -271,7 +275,7 @@ export function test(
)
},
async expectFileToContain(filePath, contents) {
return retryUntil(async () => {
return retryAssertion(async () => {
let fileContent = await this.read(filePath)
for (let content of contents) {
expect(fileContent).toContain(content)
@ -292,9 +296,11 @@ export function test(
// a separate workspace). We work around this by using the
// `--ignore-workspace` flag.
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
} catch (error: any) {
console.error(error)
console.error(error.stdout?.toString())
console.error(error.stderr?.toString())
throw error
}
@ -303,9 +309,8 @@ export function test(
async function dispose() {
await Promise.all(disposables.map((dispose) => dispose()))
// Skip removing the directory in CI beause it can stall on Windows
if (!process.env.CI && !debug) {
await fs.rm(root, { recursive: true, force: true })
if (!debug) {
await gracefullyRemove(root)
}
}
@ -319,7 +324,7 @@ test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { only: true })
}
test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { only: true, debug: true })
return test(name, config, testCallback, { debug: true })
}
// Maps package names to their tarball filenames. See scripts/pack-packages.ts
@ -478,7 +483,7 @@ export function escape(value: string) {
return result
}
async function retryUntil<T>(
export async function retryAssertion<T>(
fn: () => Promise<T>,
{ timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {},
) {
@ -494,3 +499,50 @@ async function retryUntil<T>(
}
throw error
}
export async function fetchStylesFromIndex(port: number): Promise<string> {
let index = await fetch(`http://localhost:${port}`)
let html = await index.text()
let regex = /<link rel="stylesheet" href="([a-zA-Z0-9\/_\.\?=%-]+)"/g
let paths: string[] = []
let match
while ((match = regex.exec(html)) !== null) {
let path: string = match[1]
if (path.startsWith('./')) {
path = path.slice(1)
}
paths.push(path)
}
let stylesheets = await Promise.all(
paths.map(async (path) => {
let css = await fetch(`http://localhost:${port}${path}`, {
headers: {
Accept: 'text/css',
},
})
return await css.text()
}),
)
return stylesheets.reduce((acc, css) => {
return acc + '\n' + css
})
}
async function gracefullyRemove(dir: string) {
// Skip removing the directory in CI because it can stall on Windows
if (!process.env.CI) {
await fs.rm(dir, { recursive: true, force: true })
}
}
async function dirExists(dir: string): Promise<boolean> {
try {
return await fs.stat(dir).then((stat) => stat.isDirectory())
} catch {
return false
}
}

View file

@ -1,21 +1,6 @@
import path from 'node:path'
import { expect } from 'vitest'
import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
async function fetchCSS(pathname: string, port: number) {
// We need to fetch the main index.html file to populate the list of
// candidates.
let body = await fetch(`http://localhost:${port}`)
// Make sure the main request is garbage collected.
body.blob()
let response = await fetch(`http://localhost:${port}${pathname}`, {
headers: {
Accept: 'text/css',
},
})
return response.text()
}
import { candidate, css, fetchStylesFromIndex, html, js, json, test, ts, yaml } from '../utils'
test(
'production build',
@ -140,7 +125,7 @@ test(
await process.onStdout((message) => message.includes('ready in'))
let css = await fetchCSS('/src/index.css', port)
let css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`underline`)
await fs.write(
@ -156,7 +141,7 @@ test(
)
await process.onStdout((message) => message.includes('page reload'))
css = await fetchCSS('/src/index.css', port)
css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`m-2`)
await fs.write(
@ -168,7 +153,7 @@ test(
)
await process.onStdout((message) => message.includes('page reload'))
css = await fetchCSS('/src/index.css', port)
css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
},
)