tailwindcss/integrations/upgrade/index.test.ts
Robin Malfait e4856c9720
Make upgrade tooling more stable (#19846)
This PR is an attempt to make the upgrade tooling more stable.


### TL;DR

1. When migrating from Tailwind CSS v3 → Tailwind CSS v4, only migrate
files listed in the `config.content` instead of relying on v4's auto
content detection feature
2. Skip writing files that have not been changed
3. Write changed files in a safe way: first write to a temporary file,
then rename the file atomically
4. Never migrate files that are git ignored, even if they are listed in
the `config.content` file
5. Always ignore `.env` and `.env.*` files when scanning for files. Most
people will have this in their `.gitignore` file, but if not, then this
is a fallback mechanism.

---

Looking at the #18972 issue, it looks like some people are running into
weird situations where some of the contents is just gone.

I have never been able to reproduce this on my own devices and in my own
projects unfortunately. But there is definitely _something_ happening
that's not right that people are running into.

Therefore, this PR is an attempt to fix what I think _might_ be wrong,
but I'm not 100% sure if these fixes are enough, or if something else is
still happening here.

This builds on top of the #19779 PR which has some small fixes, but is
incomplete to make this work.

### What's happening

Looking at some of the comments, it looks like a few things are
happening such as:

1. The upgrade tool is emptying out my files — it looks like these are
only happening if you ctrl+c while the process is taking a while. It
could be that a lot of files are being checked and therefore the tooling
looks like its stuck.
6. The upgrade tool is looking at files it shouldn't look at — in
Tailwind CSS v4 we have this concept of the auto-content detection. This
means that we will look at any plain text file that is not git ignored.

### Fixes


#### Emptying out files

The files being emptied looks like it's because how `fs.writeFile`
behaves by default. It opens the file handle with the `w` flag, which
will first truncate the file before writing the new contents. This is
not a single atomic operation, so a killed process in the middle will
cause invalid state.

When we migrate your template files, everything is happening in promises
to migrate things at the same time. When a lot of files are being
scanned, truncating might have happened already before we write the new
content. Since we migrate a bunch of files in parallel, a ctrl+c could
cause data loss in multiple files.

To mitigate this, I switched to an alternative way of writing files. 

1. First, we do some quick checks where if the contents didn't change we
just bail out immediately. Files that don't include Tailwind CSS classes
won't change, and therefore we don't need to override these files with
the same contents.
2. When the migrated contents is empty, we bail out as well. I'm 100%
sure that this is not the spot where the "emptying out" happens, I still
believe it happens in the `writeFile` itself, but added it just in case.
7. Next, I introduced a safe write, where we first write to a temporary
file in the same folder. We could write it to `/tmp`, but then we can't
guarantee that we are on the same file system.
   
If we ctrl+c at this stage, then the worst case scenario is that you
have additional temporary files in your project, but your original files
are still there.
   
Once that file was written, we will use the atomic `fs.rename`. This
should be atomic as long as we are on the same file system, so either
the rename didn't happen yet, or it completed.

I added an integration test for this, but I had to change the
`writeFile` implementation slightly. In the test, we will truncate the
file first, after that we will write the new contents. This is so that
we have enough time to kill the current process and allows us to verify
that we didn't clear out the file. Again, this is a hacky way of testing
this, just because I can't reproduce this issue myself, let alone
reproduce it reliable in a CI environment.

Note: we are also using `realpath` to make sure that we are updating the
real file. Otherwise, if we were dealing with a symlinked file, we would
override the symlink with a "hard" copy instead.

#### Touching files that should not be touched

During the migration, we rely on the Tailwind CSS v4 auto detection
logic which means that it will scan any plain text file that is not git
ignored. Therefore changes to php files could happen because in theory
they could contain Tailwind CSS classes.

To solve this, when migrating from Tailwind CSS v3 to Tailwind CSS v4,
we will _only_ take the sources into account that were listed in the
`config.content` array. Since this was a requirement in Tailwind CSS v3,
it should be safe to rely on this array.

Additionally, this will make sure that we are dealing with way fewer
files to migrate as well.

On top of that, files that match the patterns in the content array that
are git ignored will also be skipped. This is to prevent that we mutate
files in `node_modules` for example.

In one of the comments I read that `.env` files were emptied out. In
most cases people will have these files gitignored but I explicitly
added `.env` and `.env.*` as files to never ever touch by default when
scanning.

Last but not least, this also updates the output a little bit of the
upgrade tool in case we skip content files (because of git ignore) and
if we changed a file.

<img width="1122" height="1376" alt="image"
src="https://github.com/user-attachments/assets/318fdbbf-e319-4c7e-9648-ee9283842624"
/>

- "Git ignored folder, skipping: `./node_modules`": this is because the
content array looks like this while the `node_modules` are being
ignored:
<img width="1090" height="398" alt="image"
src="https://github.com/user-attachments/assets/7d694720-5671-47ec-bb2e-f24c5f2c4248"
/>

- "Migrated
`./resources/views/vendor/filament-panels/components/logo.blade.php`":
this is because **I** made a change to showcase this feature.

Fixes: #18972
Closes: #19779

### Test plan

1. Existing tests still pass
1. Added a dedicated integration test to ensure that we only take
`config.content` into account when migrating from Tailwind CSS v3 to
Tailwind CSS v4 projects.
1. Added a dedicated integration test to make sure that files listed in
`config.content` that are also git ignored, will still be skipped.
1. Added a dedicated integration test to ensure that when `writeFile` is
cancelled mid-write that our old files are still present.
1. Added a dedicated integration test to ensure that we ignore `.env`
and `.env.*` files even if you didn't git ignore them.

[ci-all] To verify on Windows

---------

Co-authored-by: Sami <sychocouldy@gmail.com>
2026-03-24 17:24:12 +01:00

3449 lines
86 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import path from 'node:path'
import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git'
import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils'
test(
'error when no CSS file with @tailwind is used',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<h1>🤠👋</h1>
<div class="!flex"></div>
`,
'src/fonts.css': css`/* Unrelated CSS file */`,
},
},
async ({ fs, exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade')
expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.')
// Files should not be modified
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<h1>🤠👋</h1>
<div class="!flex"></div>
--- ./src/fonts.css ---
/* Unrelated CSS file */
"
`)
},
)
test(
`upgrades a v3 project to v4`,
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"@tailwindcss/cli": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<h1>🤠👋</h1>
<div
class="!flex sm:!block bg-gradient-to-t bg-[--my-red] max-w-screen-md ml-[theme(screens.md)] group-[]:flex"
></div>
<!-- Migrate to sm -->
<div class="blur shadow rounded inset-shadow drop-shadow"></div>
<!-- Migrate to xs -->
<div class="blur-sm shadow-sm rounded-sm inset-shadow-sm drop-shadow-sm"></div>
<!-- Migrate to 2xs -->
<div class="shadow-xs inset-shadow-xs"></div>
<!-- Migrate to -3 -->
<div class="ring"></div>
<!-- Migrate to -solid -->
<div class="outline"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
@variants hover, focus {
.foo {
color: red;
}
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<h1>🤠👋</h1>
<div
class="flex! sm:block! bg-linear-to-t bg-(--my-red) max-w-(--breakpoint-md) ml-(--breakpoint-md) in-[.group]:flex"
></div>
<!-- Migrate to sm -->
<div class="blur-sm shadow-sm rounded-sm inset-shadow-sm drop-shadow-sm"></div>
<!-- Migrate to xs -->
<div class="blur-xs shadow-xs rounded-xs inset-shadow-xs drop-shadow-xs"></div>
<!-- Migrate to 2xs -->
<div class="shadow-2xs inset-shadow-2xs"></div>
<!-- Migrate to -3 -->
<div class="ring-3"></div>
<!-- Migrate to -solid -->
<div class="outline-solid"></div>
--- ./src/input.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility foo {
color: red;
}
"
`)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringMatching(/^\^4/),
})
// Ensure the v4 project compiles correctly
await exec('npx tailwindcss --input src/input.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
candidate`flex!`,
candidate`sm:block!`,
candidate`bg-linear-to-t`,
candidate`bg-(--my-red)`,
candidate`max-w-(--breakpoint-md)`,
candidate`ml-(--breakpoint-md)`,
])
},
)
test(
'only migrates files matched by `config.content` when upgrading from v3 to v4',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.html'],
}
`,
'src/index.html': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'templates/email.php': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'notes/unrelated.txt': `order-[0] bg-[--my-red]`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(`
"
--- notes/unrelated.txt ---
order-[0] bg-[--my-red]
--- src/index.html ---
<div class="order-0 bg-(--my-red)"></div>
--- templates/email.php ---
<div class="order-[0] bg-[--my-red]"></div>
"
`)
},
)
test(
`upgrades a v3 project with prefixes to v4`,
{
// Somehow this test takes *way* longer than the rest (but not always?)
timeout: 180_000,
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
prefix: 'tw__',
}
`,
'src/index.html': html`
<div
class="!tw__flex sm:!tw__block tw__bg-gradient-to-t flex [color:red] group-[]:tw__flex"
></div>
<div
class="tw__group tw__group/foo tw__peer tw__peer/foo group-hover:tw__flex group-hover/foo:tw__flex peer-hover:tw__flex peer-hover/foo:tw__flex"
></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
.btn {
@apply !tw__rounded-md tw__px-2 tw__py-1 tw__bg-blue-500 tw__text-white;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:text-[red] tw:in-[.tw\\:group]:flex"
></div>
<div
class="tw:group tw:group/foo tw:peer tw:peer/foo tw:group-hover:flex tw:group-hover/foo:flex tw:peer-hover:flex tw:peer-hover/foo:flex"
></div>
--- ./src/input.css ---
@import 'tailwindcss' prefix(tw);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
.btn {
@apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white;
}
"
`)
},
)
test(
'migrate @apply',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/tailwind.css';
.a {
@apply flex;
}
.b {
@apply !flex;
}
.c {
@apply !flex flex-col! items-center !important;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
.a {
@apply flex;
}
.b {
@apply flex!;
}
.c {
@apply flex! flex-col! items-center!;
}
"
`)
},
)
test(
'migrate `@tailwind` directives',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@tailwind base;
html {
color: #333;
}
@tailwind components;
.btn {
color: red;
}
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
html {
color: #333;
}
}
@layer components {
.btn {
color: red;
}
}
"
`)
},
)
test(
'migrate `@layer utilities` and `@layer components`',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/tailwind.css';
@layer components {
.btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
"
`)
},
)
test(
'migrate imports with `layer(…)`',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import './base.css';
@import './components.css';
@import './utilities.css';
@import './mix.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/base.css': css`
html {
color: red;
}
`,
'src/components.css': css`
@layer components {
.foo {
color: red;
}
}
`,
'src/utilities.css': css`
@layer utilities {
.bar {
color: red;
}
}
`,
'src/mix.css': css`
html {
color: blue;
}
@layer components {
.foo-mix {
color: red;
}
}
@layer utilities {
.bar-mix {
color: red;
}
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import './base.css' layer(base);
@import './components.css';
@import './utilities.css';
@import './mix.css' layer(base);
@import './mix.utilities.css';
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/base.css ---
html {
color: red;
}
--- ./src/components.css ---
@utility foo {
color: red;
}
--- ./src/mix.css ---
html {
color: blue;
}
--- ./src/mix.utilities.css ---
@utility foo-mix {
color: red;
}
@utility bar-mix {
color: red;
}
--- ./src/utilities.css ---
@utility bar {
color: red;
}
"
`)
},
)
test(
'migrates a simple postcss setup',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': 'postcss-nesting',
tailwindcss: {},
autoprefixer: {},
},
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain(
'postcss.config.js',
js`
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
)
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-(--my-red)"></div>
`,
)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringMatching(/^\^4/),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.dependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringMatching(/^\^4/),
})
},
)
test(
'`@tailwindcss/postcss` should be installed in dependencies when `tailwindcss` exists in dependencies',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
tailwindcss: {},
},
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringMatching(/^\^4/),
})
},
)
test(
'`@tailwindcss/postcss` should be installed in devDependencies when `tailwindcss` exists in dev dependencies',
{
fs: {
'package.json': json`
{
"devDependencies": {
"postcss": "^8",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
tailwindcss: {},
},
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.devDependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringMatching(/^\^4/),
})
},
)
test(
'migrates a postcss setup using package.json config',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"postcss": {
"plugins": {
"postcss-import": {},
"tailwindcss/nesting": "postcss-nesting",
"tailwindcss": {},
"autoprefixer": {}
}
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-(--my-red)"></div>
`,
)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.postcss).toMatchInlineSnapshot(`
{
"plugins": {
"@tailwindcss/postcss": {},
},
}
`)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringMatching(/^\^4/),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.dependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringMatching(/^\^4/),
})
},
)
test(
'migrates a postcss setup using a json based config file',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"autoprefixer": "^10",
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'.postcssrc.json': json`
{
"plugins": {
"postcss-import": {},
"tailwindcss/nesting": "postcss-nesting",
"tailwindcss": {},
"autoprefixer": {}
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div class="bg-[--my-red]"></div>
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
'src/index.html',
// prettier-ignore
js`
<div class="bg-(--my-red)"></div>
`,
)
let jsonConfigContent = await fs.read('.postcssrc.json')
let jsonConfig = JSON.parse(jsonConfigContent)
expect(jsonConfig).toMatchInlineSnapshot(`
{
"plugins": {
"@tailwindcss/postcss": {},
},
}
`)
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringMatching(/^\^4/),
})
expect(packageJson.dependencies).not.toHaveProperty('autoprefixer')
expect(packageJson.dependencies).not.toHaveProperty('postcss-import')
expect(packageJson.dependencies).toMatchObject({
'@tailwindcss/postcss': expect.stringMatching(/^\^4/),
})
},
)
test(
`migrates prefixes even if other files have unprefixed versions of the candidate`,
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
prefix: 'tw__',
}
`,
'src/index.html': html`
<div class="flex"></div>
`,
'src/other.html': html`
<div class="tw__flex"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="flex"></div>
--- ./src/other.html ---
<div class="tw:flex"></div>
"
`)
},
)
test(
`prefixed variants do not cause their unprefixed counterparts to be valid`,
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
prefix: 'tw__',
}
`,
'src/index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/index.html': html`
<div class="tw__bg-gradient-to-t"></div>
`,
'src/other.html': html`
<div class="bg-gradient-to-t"></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="tw:bg-linear-to-t"></div>
--- ./src/other.html ---
<div class="bg-gradient-to-t"></div>
"
`)
},
)
test(
'migrate utilities in an imported file',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/tailwind.css';
@import './utilities.css' layer(utilities);
`,
'src/utilities.css': css`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss';
@import './utilities.css';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/utilities.css ---
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
"
`)
},
)
test(
'migrate utilities in an imported file and keep @utility top-level',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@import './utilities.css';
@import 'tailwindcss/components';
`,
'src/utilities.css': css`
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './utilities.css';
--- ./src/utilities.css ---
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
"
`)
},
)
test(
'migrate utilities in deep import trees',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/cli": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.html': html`
<div class="hover:thing"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css' layer(utilities);
@import './b.1.css' layer(components);
@import './c.1.css';
@import './d.1.css';
`,
'src/a.1.css': css`
@import './a.1.utilities.css';
.foo-from-a {
color: red;
}
`,
'src/a.1.utilities.css': css`
#foo {
--keep: me;
}
.foo-from-import {
color: blue;
}
`,
'src/b.1.css': css`
@import './b.1.components.css';
.bar-from-b {
color: red;
}
`,
'src/b.1.components.css': css`
.bar-from-import {
color: blue;
}
`,
'src/c.1.css': css`
@import './c.2.css' layer(utilities);
.baz-from-c {
color: green;
}
`,
'src/c.2.css': css`
@import './c.3.css';
#baz {
--keep: me;
}
.baz-from-import {
color: yellow;
}
`,
'src/c.3.css': css`
#baz {
--keep: me;
}
.baz-from-import {
color: yellow;
}
`,
// This is a super deep import chain
// And no `*.utilities.css` files should be created for these
// because there are no rules that need to be separated
'src/d.1.css': css`@import './d.2.css' layer(utilities);`,
'src/d.2.css': css`@import './d.3.css';`,
'src/d.3.css': css`@import './d.4.css';`,
'src/d.4.css': css`
.from-a-4 {
color: blue;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
@import './a.1.utilities.1.css';
@import './b.1.css';
@import './c.1.css' layer(utilities);
@import './c.1.utilities.css';
@import './d.1.css';
--- ./src/a.1.css ---
@import './a.1.utilities.css'
--- ./src/a.1.utilities.1.css ---
@import './a.1.utilities.utilities.css';
@utility foo-from-a {
color: red;
}
--- ./src/a.1.utilities.css ---
#foo {
--keep: me;
}
--- ./src/a.1.utilities.utilities.css ---
@utility foo-from-import {
color: blue;
}
--- ./src/b.1.components.css ---
@utility bar-from-import {
color: blue;
}
--- ./src/b.1.css ---
@import './b.1.components.css';
@utility bar-from-b {
color: red;
}
--- ./src/c.1.css ---
@import './c.2.css' layer(utilities);
.baz-from-c {
color: green;
}
--- ./src/c.1.utilities.css ---
@import './c.2.utilities.css'
--- ./src/c.2.css ---
@import './c.3.css';
#baz {
--keep: me;
}
--- ./src/c.2.utilities.css ---
@import './c.3.utilities.css';
@utility baz-from-import {
color: yellow;
}
--- ./src/c.3.css ---
#baz {
--keep: me;
}
--- ./src/c.3.utilities.css ---
@utility baz-from-import {
color: yellow;
}
--- ./src/d.1.css ---
@import './d.2.css'
--- ./src/d.2.css ---
@import './d.3.css'
--- ./src/d.3.css ---
@import './d.4.css'
--- ./src/d.4.css ---
@utility from-a-4 {
color: blue;
}
"
`)
},
)
test(
'migrate utility files imported by multiple roots',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/cli": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.html': html`
<div class="hover:thing"></div>
`,
'src/root.1.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css' layer(utilities);
`,
'src/root.2.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css' layer(components);
`,
'src/root.3.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css';
`,
'src/a.1.css': css`
.foo-from-a {
color: red;
}
`,
},
},
async ({ exec, fs, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force')
expect(output).toMatch(
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
)
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/a.1.css ---
.foo-from-a {
color: red;
}
--- ./src/root.1.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
--- ./src/root.2.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(components);
--- ./src/root.3.css ---
@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
"
`)
},
)
test(
'injecting `@config` when a tailwind.config.{js,ts,…} is detected',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {}, // custom stuff which is too complicated to migrate to CSS
],
}
`,
'src/index.html': html`
<div
class="!flex sm:!block bg-gradient-to-t bg-[--my-red]"
></div>
`,
'src/root.1/index.css': css`
/* Inject missing @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/root.1/tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {}, // custom stuff which is too complicated to migrate to CSS
],
}
`,
'src/root.2/index.css': css`
/* Already contains @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
@config "../../tailwind.config.ts";
`,
'src/root.3/index.css': css`
/* Inject missing @config above first @theme */
@tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant hocus (&:hover, &:focus);
@theme {
--color-red-500: #f00;
}
@theme {
--color-blue-500: #00f;
}
`,
'src/root.3/tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {}, // custom stuff which is too complicated to migrate to CSS
],
}
`,
'src/root.4/index.css': css`
/* Inject missing @config due to nested imports with tailwind imports */
@import './base.css';
@import './utilities.css';
`,
'src/root.4/tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {}, // custom stuff which is too complicated to migrate to CSS
],
}
`,
'src/root.4/base.css': css`@import 'tailwindcss/base';`,
'src/root.4/utilities.css': css`@import 'tailwindcss/utilities';`,
'src/root.5/index.css': css`@import './tailwind.css';`,
'src/root.5/tailwind.css': css`
/* Inject missing @config in this file, due to full import */
/* Should be located in the root: ../../ */
@import 'tailwindcss';
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/root.1/index.css ---
/* Inject missing @config */
@import 'tailwindcss';
@config './tailwind.config.ts';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/root.2/index.css ---
/* Already contains @config */
@import 'tailwindcss';
@config "../../tailwind.config.ts";
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/root.3/index.css ---
/* Inject missing @config above first @theme */
@import 'tailwindcss';
@config './tailwind.config.ts';
@custom-variant hocus (&:hover, &:focus);
@theme {
--color-red-500: #f00;
}
@theme {
--color-blue-500: #00f;
}
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/root.4/index.css ---
/* Inject missing @config due to nested imports with tailwind imports */
@import './base.css';
@import './utilities.css';
@config './tailwind.config.ts';
--- ./src/root.4/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/root.4/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
--- ./src/root.5/index.css ---
@import './tailwind.css';
--- ./src/root.5/tailwind.css ---
/* Inject missing @config in this file, due to full import */
/* Should be located in the root: ../../ */
@import 'tailwindcss';
@config '../../tailwind.config.ts';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
"
`)
},
)
test(
'multiple CSS roots that resolve to the same Tailwind config file requires manual intervention',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {}, // custom stuff which is too complicated to migrate to CSS
],
}
`,
'src/index.html': html`
<div
class="!flex sm:!block bg-gradient-to-t bg-[--my-red]"
></div>
`,
'src/root.1.css': css`
/* Inject missing @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/root.2.css': css`
/* Already contains @config */
@tailwind base;
@tailwind components;
@tailwind utilities;
@config "../tailwind.config.ts";
`,
'src/root.3.css': css`
/* Inject missing @config above first @theme */
@tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant hocus (&:hover, &:focus);
@theme {
--color-red-500: #f00;
}
@theme {
--color-blue-500: #00f;
}
`,
'src/root.4.css': css`
/* Inject missing @config due to nested imports with tailwind imports */
@import './root.4/base.css';
@import './root.4/utilities.css';
`,
'src/root.4/base.css': css`@import 'tailwindcss/base';`,
'src/root.4/utilities.css': css`@import 'tailwindcss/utilities';`,
'src/root.5.css': css`@import './root.5/tailwind.css';`,
'src/root.5/tailwind.css': css`
/* Inject missing @config in this file, due to full import */
@import 'tailwindcss/tailwind.css';
`,
},
},
async ({ exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch(
(e) => e.toString(),
)
expect(output).toMatch('Could not determine configuration file for:')
},
)
test(
'injecting `@config` in the shared root, when a tailwind.config.{js,ts,…} is detected',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
plugins: [
() => {
// custom stuff which is too complicated to migrate to CSS
},
],
}
`,
'src/index.html': html`
<div
class="!flex sm:!block bg-gradient-to-t bg-[--my-red]"
></div>
`,
'src/index.css': css`@import './tailwind-setup.css';`,
'src/tailwind-setup.css': css`
@import './base.css';
@import './components.css';
@import './utilities.css';
`,
'src/base.css': css`
html {
color: red;
}
@tailwind base;
`,
'src/components.css': css`
@import './typography.css';
@layer components {
.foo {
color: red;
}
}
@tailwind components;
`,
'src/typography.css': css`
.typography {
color: red;
}
`,
'src/utilities.css': css`
@layer utilities {
.bar {
color: red;
}
}
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import './tailwind-setup.css';
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
html {
color: red;
}
}
--- ./src/components.css ---
@import './typography.css' layer(components);
@utility foo {
color: red;
}
--- ./src/tailwind-setup.css ---
@import './base.css';
@import './components.css';
@import './utilities.css';
@config '../tailwind.config.ts';
--- ./src/typography.css ---
.typography {
color: red;
}
--- ./src/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
@utility bar {
color: red;
}
"
`)
},
)
test(
'injecting `@config` in the shared root (+ migrating), when a tailwind.config.{js,ts,…} is detected',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
theme: {
extend: {
colors: {
'my-red': 'red',
},
},
},
}
`,
'src/index.html': html`
<div
class="!flex sm:!block bg-gradient-to-t bg-[--my-red]"
></div>
`,
'src/index.css': css`@import './tailwind-setup.css';`,
'src/tailwind-setup.css': css`
@import './base.css';
@import './components.css';
@import './utilities.css';
`,
'src/base.css': css`
html {
color: red;
}
@tailwind base;
`,
'src/components.css': css`
@import './typography.css';
@layer components {
.foo {
color: red;
}
}
@tailwind components;
`,
'src/typography.css': css`
.typography {
color: red;
}
`,
'src/utilities.css': css`
@layer utilities {
.bar {
color: red;
}
}
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import './tailwind-setup.css';
--- ./src/index.html ---
<div
class="flex! sm:block! bg-linear-to-t bg-(--my-red)"
></div>
--- ./src/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
html {
color: red;
}
}
--- ./src/components.css ---
@import './typography.css' layer(components);
@utility foo {
color: red;
}
--- ./src/tailwind-setup.css ---
@import './base.css';
@import './components.css';
@import './utilities.css';
@theme {
--color-my-red: red;
}
--- ./src/typography.css ---
.typography {
color: red;
}
--- ./src/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
@utility bar {
color: red;
}
"
`)
},
)
test(
'finds the correct root Tailwind CSS file',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': js`export default {}`,
/* Considered a Tailwind root because of: `@import 'tailwindcss/components'` */
'src/index.css': css`
@import 'base.css';
@import 'other.css';
@import 'tailwindcss/components';
@import 'utilities.css';
`,
/* Considered a Tailwind root because of: `@tailwind base` */
'src/base.css': css`
html {
color: red;
}
@tailwind base;
`,
'src/other.css': css`
.typography {
color: red;
}
`,
/* Considered a Tailwind root because of: `@tailwind utilities` */
'src/utilities.css': css`
@layer utilities {
.foo {
color: red;
}
}
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import './base.css' layer(components);
@import './other.css' layer(components);
@import './utilities.css';
--- ./src/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
html {
color: red;
}
}
--- ./src/other.css ---
.typography {
color: red;
}
--- ./src/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
@utility foo {
color: red;
}
"
`)
},
)
test(
'relative imports without a relative path prefix are migrated to include a relative path prefix',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'styles/components';
@import 'tailwindcss/utilities';
`,
'src/styles/components.css': css`
.btn {
@apply bg-black px-4 py-2 rounded-md text-white font-medium hover:bg-zinc-800;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import 'tailwindcss';
@import './styles/components.css' layer(components);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/styles/components.css ---
.btn {
@apply bg-black px-4 py-2 rounded-md text-white font-medium hover:bg-zinc-800;
}
"
`)
},
)
test(
'that it attaches the correct layers to the imported files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import 'tailwindcss/components';
/* No layer expected */
@import './my-components.css';
/* No layer expected */
@import './my-utilities.css';
/* Expecting a layer */
@import './my-other.css';
@import 'tailwindcss/utilities';
`,
'src/my-components.css': css`
@layer components {
.foo {
color: red;
}
}
`,
'src/my-utilities.css': css`
@layer utilities {
.css {
color: red;
}
}
`,
'src/my-other.css': css`
/* All my fonts! */
@font-face {
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
/* No layer expected */
@import './my-components.css';
/* No layer expected */
@import './my-utilities.css';
/* Expecting a layer */
@import './my-other.css' layer(components);
@import 'tailwindcss/utilities' layer(utilities);
--- ./src/my-components.css ---
@utility foo {
color: red;
}
--- ./src/my-other.css ---
/* All my fonts! */
@font-face {
}
--- ./src/my-utilities.css ---
@utility css {
color: red;
}
"
`)
},
)
test(
'migrating the prettier-plugin-tailwindcss version',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "0.5.0"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
let pkg = JSON.parse(await fs.read('package.json'))
expect(pkg.devDependencies).toMatchObject({
'prettier-plugin-tailwindcss': expect.any(String),
})
expect(pkg.devDependencies['prettier-plugin-tailwindcss']).not.toEqual('0.5.0')
},
)
test(
'only migrate legacy classes when it is safe to do so',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "0.5.0"
}
}
`,
'tailwind.config.js': js`
module.exports = {
content: ['./*.html'],
theme: {
// Overrides the default boxShadow entirely so none of the
// migrations are safe.
boxShadow: {
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
ringWidth: {
DEFAULT: '4px',
},
extend: {
// Changes the "before" class definition. 'blur' -> 'blur-sm' is
// not safe because 'blur' has a custom value.
//
// But 'blur-sm' -> 'blur-xs' is safe because 'blur-xs' uses the
// default value.
blur: {
DEFAULT: 'var(--custom-default-blur)',
},
backdropBlur: {
DEFAULT: 'var(--custom-default-blur)',
},
// Changes the "after" class definition. 'rounded' -> 'rounded-sm' is
// not safe because 'rounded-sm' has a custom value.
borderRadius: {
sm: 'var(--custom-rounded-sm)',
},
},
},
}
`,
'index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'index.html': html`
<div>
<div class="shadow shadow-sm shadow-xs"></div>
<div class="blur blur-sm"></div>
<div class="backdrop-blur backdrop-blur-sm"></div>
<div class="rounded rounded-sm"></div>
<div class="ring"></div>
</div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(`
"
--- index.css ---
@import 'tailwindcss';
@theme {
--shadow-*: initial;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--default-ring-width: 4px;
--blur: var(--custom-default-blur);
--backdrop-blur: var(--custom-default-blur);
--radius-sm: var(--custom-rounded-sm);
}
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- index.html ---
<div>
<div class="shadow shadow-sm shadow-xs"></div>
<div class="blur blur-xs"></div>
<div class="backdrop-blur backdrop-blur-xs"></div>
<div class="rounded rounded-sm"></div>
<div class="ring"></div>
</div>
"
`)
},
)
test(
'make suffix-less migrations safe (e.g.: `blur`, `rounded`, `shadow`, `ring`)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "0.5.0"
}
}
`,
'tailwind.config.js': js`
module.exports = {
content: ['./*.{html,tsx}'],
}
`,
'index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'index.html': html`
<div class="rounded blur shadow ring"></div>
`,
'example-component.tsx': ts`
type Star = [
x: number,
y: number,
dim?: boolean,
blur?: boolean,
rounded?: boolean,
shadow?: boolean,
ring?: boolean,
]
function Star({ point: [cx, cy, dim, blur, rounded, shadow, ring] }: { point: Star }) {
return <svg class="rounded shadow blur ring" filter={blur ? 'url(…)' : undefined} />
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
"
--- index.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- index.html ---
<div class="rounded-sm blur-sm shadow-sm ring-3"></div>
--- example-component.tsx ---
type Star = [
x: number,
y: number,
dim?: boolean,
blur?: boolean,
rounded?: boolean,
shadow?: boolean,
ring?: boolean,
]
function Star({ point: [cx, cy, dim, blur, rounded, shadow, ring] }: { point: Star }) {
return <svg class="rounded-sm shadow-sm blur-sm ring-3" filter={blur ? 'url(…)' : undefined} />
}
"
`)
},
)
test(
'passing in a single CSS file should resolve all imports and migrate them',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`module.exports = {}`,
'src/index.css': css`
@import './base.css';
@import './components.css';
@import './utilities.css';
@import './generated/ignore-me.css';
`,
'src/generated/.gitignore': `
*
!.gitignore
`,
'src/generated/ignore-me.css': css`
/* This should not be converted */
@layer utilities {
.ignore-me {
color: red;
}
}
`,
'src/base.css': css`@import 'tailwindcss/base';`,
'src/components.css': css`
@import './typography.css';
@layer components {
.foo {
color: red;
}
}
@tailwind components;
`,
'src/utilities.css': css`
@layer utilities {
.bar {
color: blue;
}
}
@tailwind utilities;
`,
'src/typography.css': css`
@layer components {
.typography {
color: red;
}
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade ./src/index.css')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.css ---
@import './base.css';
@import './components.css';
@import './utilities.css';
@import './generated/ignore-me.css';
--- ./src/base.css ---
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(base);
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- ./src/components.css ---
@import './typography.css';
@utility foo {
color: red;
}
--- ./src/typography.css ---
@utility typography {
color: red;
}
--- ./src/utilities.css ---
@import 'tailwindcss/utilities' layer(utilities);
@utility bar {
color: blue;
}
--- ./src/generated/ignore-me.css ---
/* This should not be converted */
@layer utilities {
.ignore-me {
color: red;
}
}
"
`)
},
)
test(
`upgrades opacity namespace values to percentages`,
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"@tailwindcss/cli": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
opacity: {
0: '0',
2.5: '.025',
5: '.05',
7.5: 0.075,
10: 0.1,
semitransparent: '0.5',
transparent: 1,
50: '50%',
50.5: '50.5%',
'50.50': '50.5%',
'75%': '75%',
'100%': '100%',
},
},
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div
class="text-red-500/0
text-red-500/2.5
text-red-500/5
text-red-500/7.5
text-red-500/10
text-red-500/semitransparent
text-red-500/transparent
text-red-500/50
text-red-500/50.5
text-red-500/50.50
text-red-500/50%
text-red-500/100%"
></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div
class="text-red-500/0
text-red-500/2.5
text-red-500/5
text-red-500/7.5
text-red-500/10
text-red-500/semitransparent
text-red-500/transparent
text-red-500/50
text-red-500/50.5
text-red-500/50.50
text-red-500/50%
text-red-500/100%"
></div>
--- ./src/input.css ---
@import 'tailwindcss';
@theme {
--opacity-*: initial;
--opacity-semitransparent: 50%;
--opacity-transparent: 100%;
--opacity-50_50: 50.5%;
--opacity-75\\%: 75%;
--opacity-100\\%: 100%;
}
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
"
`)
},
)
test(
`can read files with BOM`,
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"@tailwindcss/cli": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': withBOM(html`
<div class="ring"></div>
`),
'src/input.css': withBOM(css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`),
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="ring-3"></div>
--- ./src/input.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
"
`)
},
)
test(
'upgrades are idempotent, and can run on v4 projects',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"@tailwindcss/cli": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
}
`,
'src/index.html': html`
<div class="ring"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
.foo {
@apply !bg-[var(--my-color)] rounded;
}
`,
},
},
async ({ exec, root, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
let before = await fs.dumpFiles('./src/**/*.{css,html}')
expect(before).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="ring-3"></div>
--- ./src/input.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
.foo {
@apply bg-(--my-color)! rounded-sm;
}
"
`)
// Commit the changes
if (isRepoDirty(root)) {
await exec('git add .')
await exec('git commit -m "upgrade"')
}
// Run the upgrade again
let output = await exec('npx @tailwindcss/upgrade')
expect(output).toContain('No changes were made to your repository')
let after = await fs.dumpFiles('./src/**/*.{css,html}')
expect(after).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="ring-3"></div>
--- ./src/input.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
.foo {
@apply bg-(--my-color)! rounded-sm;
}
"
`)
// Ensure the file system is in the same state
expect(before).toEqual(after)
},
)
test(
'upgrades run on v4 projects',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.html': html`
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="!flex bg-red-500/[var(--my-opacity)] [@media(pointer:fine)]:flex bg-right-bottom object-left-top"
></div>
`,
'src/input.css': css`
@import 'tailwindcss';
.foo {
@apply !bg-[var(--my-color)];
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="flex! bg-red-500/(--my-opacity) pointer-fine:flex bg-bottom-right object-top-left"
></div>
--- ./src/input.css ---
@import 'tailwindcss';
.foo {
@apply bg-(--my-color)!;
}
"
`)
},
)
test(
'v4 ignores .env files during template migration',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css`@import 'tailwindcss';`,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'src/.env': `TW_TEST_CLASS=order-[0]`,
'src/.env.production': `TW_TEST_CLASS=order-[0]`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="order-0"></div>
--- ./src/.env ---
TW_TEST_CLASS=order-[0]
--- ./src/.env.production ---
TW_TEST_CLASS=order-[0]
--- ./src/app.css ---
@import 'tailwindcss';
"
`)
},
)
test(
'v4 linked configs respect `content` and still ignore gitignored files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/*.html', './src/*.less'],
}
`,
'src/app.css': css`
@import 'tailwindcss';
@config '../tailwind.config.js';
`,
// Ignore all .html files
'src/.gitignore': txt`
*.html
`,
// HTML files are in .gitignore, even though they are explicitly mentioned
// in the `content` array. Still ignore them
'src/do-not-migrate-me.html': html`
<div class="order-[0]"></div>
`,
// Should be picked up by auto-content detection
'templates/migrate-me.php': html`
<div class="order-[0]"></div>
`,
// Does not get picked up by auto content detection (because it's a less
// file), but was explicitly listed in the `content` array.
//
// A bit of a hacky way, I admit, but it allows us to differentiate
// between git ignored files, auto content detection and explicitly listed
// files.
'src/migrate-me.less': html`
<div class="order-[0]"></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(`
"
--- ./src/app.css ---
@import 'tailwindcss';
@config '../tailwind.config.js';
--- ./src/do-not-migrate-me.html ---
<div class="order-[0]"></div>
--- ./src/migrate-me.less ---
<div class="order-0"></div>
--- ./templates/migrate-me.php ---
<div class="order-0"></div>
"
`)
},
)
test(
'interrupting template migration does not truncate files',
{
timeout: 180_000,
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css` @import 'tailwindcss'; `,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'hook.cjs': js`
let fs = require('node:fs/promises')
let path = require('node:path')
let originalWriteFile = fs.writeFile.bind(fs)
fs.writeFile = async (file, contents, ...rest) => {
// Mimic a bad write
await originalWriteFile(file, '') // As-if we truncated first
console.error('__TRUNCATED_TARGET__')
await new Promise((r) => setTimeout(r, 50)) // Wait 50ms to allow us to kill the process
await originalWriteFile(file, contents, ...rest) // Write the actual contents
}
`,
'src/keep.php': `
<?php
return [
'keep' => 'this file should never be truncated',
];
`,
},
},
async ({ spawn, fs, expect }) => {
let repeatedCandidates = Array.from(
{ length: 250 },
() => '<div class="order-[0]"></div>',
).join('\n')
for (let i = 0; i < 100; i++) {
await fs.write(
`src/templates/template-${i}.php`,
`<?php\n\n${repeatedCandidates}\n\nreturn ['template' => ${i}];\n`,
)
}
let originalKeepFile = await fs.read('src/keep.php')
let originalTemplate = await fs.read('src/templates/template-0.php')
let process = await spawn('npx @tailwindcss/upgrade --force', {
env: {
NODE_OPTIONS: '--require=./hook.cjs',
},
})
// We're only interested once we start migrating the templates
await process.onStderr((message) => message.includes('Migrating templates'))
// Wait for the trigger that we are mid-write
await process.onStderr((message) => message === '__TRUNCATED_TARGET__')
// Kill the process
await process.dispose()
expect(await fs.read('src/keep.php')).toBe(originalKeepFile)
expect(await fs.read('src/templates/template-0.php')).toBe(originalTemplate)
for (let [file, contents] of await fs.glob('src/**/*.{html,php,css}')) {
expect(contents.trim(), `${file} should not be empty after interruption`).not.toBe('')
}
},
)
test(
'upgrades can run in a pnpm workspace',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "^4"
},
"devDependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'project-a/src/index.html': html`
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="!flex bg-red-500/[var(--my-opacity)] [@media(pointer:fine)]:flex bg-right-bottom object-left-top"
></div>
`,
'project-a/src/input.css': css`
@import 'tailwindcss';
.foo {
@apply !bg-[var(--my-color)];
}
`,
},
},
async ({ root, exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade', {
cwd: path.join(root, 'project-a'),
})
expect(/Path .*? is not in cwd/.test(stdout)).toBe(false)
expect(await fs.dumpFiles('./project-a/src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./project-a/src/index.html ---
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="flex! bg-red-500/(--my-opacity) pointer-fine:flex bg-bottom-right object-top-left"
></div>
--- ./project-a/src/input.css ---
@import 'tailwindcss';
.foo {
@apply bg-(--my-color)!;
}
"
`)
},
)
test(
'upgrades can run in a pnpm workspace root',
{
fs: {
'pnpm-workspace.yaml': yaml`
#
packages:
- .
`,
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4"
},
"devDependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.html': html`
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="!flex bg-red-500/[var(--my-opacity)] [@media(pointer:fine)]:flex bg-right-bottom object-left-top"
></div>
`,
'src/input.css': css`
@import 'tailwindcss';
.foo {
@apply !bg-[var(--my-color)];
}
`,
},
},
async ({ exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade')
expect(stdout).not.toContain(
'Running this command will add the dependency to the workspace root',
)
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
<div class="ring rounded outline"></div>
<!-- Variant order is also unsafe to change in v4 projects -->
<div class="file:hover:flex *:hover:flex"></div>
<div class="hover:file:flex hover:*:flex"></div>
<!-- These are safe to migrate: -->
<div
class="flex! bg-red-500/(--my-opacity) pointer-fine:flex bg-bottom-right object-top-left"
></div>
--- ./src/input.css ---
@import 'tailwindcss';
.foo {
@apply bg-(--my-color)!;
}
"
`)
},
)
test(
'upgrade <style> blocks carefully',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.vue': html`
<template>
<div class="!flex"></div>
</template>
<style>
@reference "./input.css";
.foo {
@apply !bg-red-500;
}
.bar {
/* Do not upgrade the key: */
flex-shrink: 0;
}
</style>
`,
'src/input.css': css`
@import 'tailwindcss';
.foo {
flex-shrink: 1;
}
.bar {
@apply !underline;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(`
"
--- ./src/index.vue ---
<template>
<div class="flex!"></div>
</template>
<style>
@reference "./input.css";
.foo {
@apply !bg-red-500;
}
.bar {
/* Do not upgrade the key: */
flex-shrink: 0;
}
</style>
--- ./src/input.css ---
@import 'tailwindcss';
.foo {
flex-shrink: 1;
}
.bar {
@apply underline!;
}
"
`)
},
)
function withBOM(text: string): string {
return '\uFEFF' + text
}