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:
parent
a902128640
commit
e10b786437
3 changed files with 230 additions and 39 deletions
154
integrations/postcss/next.test.ts
Normal file
154
integrations/postcss/next.test.ts
Normal 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`)
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']`)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue