From 2a29c29441cfff103b780d74f4d3afcf830f0459 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 12 Dec 2024 13:48:56 +0100 Subject: [PATCH] Improve integration tests (stability + performance) (#15125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves the integration tests in two ways: 1. Make the integration tests more reliable and thus less flakey 2. Make the integration tests faster (by introducing concurrency) Tried a lot of different things to make sure that these tests are fast and stable. --- The biggest issue we noticed is that some tests are flakey, these are tests with long running dev-mode processes where watchers are being used and/or dev servers are created. To solve this, all the tests that spawn a process look at stdout/stderr and wait for a message from the process to know whether we can start making changes. For example, in case of an Astro project, you get a `watching for file changes` message. In case of Nuxt project you can wait for an `server warmed up in` and in case of Next.js there is a `Ready in` message. These depend on the tools being used, so this is hardcoded per test instead of a magically automatic solution. These messages allow us to wait until all the initial necessary work, internal watchers and/or dev servers are setup before we start making changes to the files and/or request CSS stylesheets before the server(s) are ready. --- Another improvement is how we setup the dev servers. Before, we used to try and get a free port on the system and use a `--port` flag or a `PORT` environment variable. Instead of doing this (which is slow), we rely on the process itself to show a URL with a port. Basically all tools will try to find a free port if the default port is in use. We can then use the stdout/stderr messages to get the URL and the port to use. To reduce the amount of potential conflicts in ports, we used to run every test and every file sequentially to basically guarantee that ports are free. With this new approach where we rely on the process, I noticed that we don't really run into this issue again (I reran the tests multiple times and they were always stable) image Note: these tests run Linux, Windows and macOS in this branch just for testing purposes. Once this is done, we will only run Linux tests on PRs and run all 3 of them on the `next` branch. We do make the tests concurrent by default now, which in theory means that there could be conflicts (which in practice means that the process has to do a few more tries to find a free port). To reduce these conflicts, we split up the integration tests such that Vite, PostCSS, CLI, … tests all run in a separate job in the GitHub actions workflow. image Comparing this branch against the `next` branch, this is what CI looks like right now: | `next` | `feat/improve-integration-tests` | | --- | --- | | image | image | There also was a point in time where I introduced sequential tests such that all spawned processes still run after each other, but so far I didn't run into issues if we keep them concurrent so I dropped that code. Some small changes I made to make things more reliable: 1. When relying on stdout/stderr messages, we split lines on `\n` and we strip all the ANSI escapes which allows us to not worry about special ANSI characters when finding the URL or a specific message to wait for. 2. Once a test is done, we `child.kill()` the spawned process. If that doesn't work, for whatever reason, we run a `child.kill('SIGKILL')` to force kill the process. This could technically lead to some memory or files not being cleaned up properly, but once CI is done, everything is thrown away anyway. 3. As you can see in the screenshots, I used some nicer names for the workflows. | `next` | `feat/improve-integration-tests` | | --- | --- | | image | image | They also look a bit nicer in the PR overview as well: image The very last commit just filters out Windows and macOS tests again for PRs (but they are executed on the `next` branch. --- ### Nest steps I think for now we are in a pretty good state, but there are some things we can do to further improve everything (mainly make things faster) but aren't necessary. I also ran into issue while trying it so there is more work to do. 1. More splits — instead of having a Vite folder and PostCSS folder, we can go a step further and have folders for Next.js, Astro, Nuxt, Remix, … 2. Caching — right now we have to run the build step for every OS on every "job". We can re-use the work here by introducing a setup job that the other jobs rely on. @thecrypticace and I tried it already, but were running into some Bun specific Standalone CLI issues when doing that. 3. Remote caching — we could re-enable remote caching such that the `build` step can be full turbo (e.g.: after a PR is merged in `next` and we run everything again) --- .github/workflows/ci.yml | 5 - .github/workflows/integration-tests.yml | 102 + integrations/cli/config.test.ts | 15 +- integrations/cli/index.test.ts | 21 +- integrations/package.json | 3 +- integrations/postcss/config.test.ts | 6 +- .../postcss/core-as-postcss-plugin.test.ts | 10 +- integrations/postcss/index.test.ts | 8 +- integrations/postcss/next.test.ts | 46 +- integrations/upgrade/index.test.ts | 63 +- integrations/upgrade/js-config.test.ts | 34 +- integrations/utils.ts | 123 +- integrations/vite/astro.test.ts | 20 +- integrations/vite/config.test.ts | 39 +- integrations/vite/css-modules.test.ts | 116 +- integrations/vite/index.test.ts | 1298 ++--- integrations/vite/multi-root.test.ts | 22 +- integrations/vite/nuxt.test.ts | 47 +- integrations/vite/other-transforms.test.ts | 220 +- integrations/vite/resolvers.test.ts | 271 +- integrations/vite/ssr.test.ts | 7 +- integrations/vite/svelte.test.ts | 8 +- integrations/vite/url-rewriting.test.ts | 170 +- integrations/vite/vue.test.ts | 3 +- integrations/vitest.config.ts | 7 + package.json | 2 +- pnpm-lock.yaml | 4713 ++++++++++++----- 27 files changed, 4855 insertions(+), 2524 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 integrations/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d28a1e1e0..62463ac4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,11 +77,6 @@ jobs: - name: Test run: pnpm run test - - name: Integration Tests - run: pnpm run test:integrations - env: - GITHUB_WORKSPACE: ${{ github.workspace }} - - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..d61223ae7 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,102 @@ +name: Integration Tests + +on: + push: + branches: [next] + pull_request: + +permissions: + contents: read + +jobs: + tests: + strategy: + fail-fast: false + matrix: + node-version: [20] + + runner: + - name: Windows + os: windows-latest + + - name: Linux + os: namespace-profile-default + + - name: macOS + os: macos-14 + + integration: + - upgrade + - vite + - cli + - postcss + + # Exclude windows and macos from being built on feature branches + on-next-branch: + - ${{ github.ref == 'refs/heads/next' }} + exclude: + - on-next-branch: false + runner: + name: Windows + - on-next-branch: false + runner: + name: macOS + + runs-on: ${{ matrix.runner.os }} + timeout-minutes: 30 + + name: ${{ matrix.runner.name }} / ${{ matrix.integration }} + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./target/ + ./crates/node/*.node + ./crates/node/index.js + ./crates/node/index.d.ts + key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm run build + env: + CARGO_PROFILE_RELEASE_LTO: 'off' + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link' + + - name: Test ${{ matrix.integration }} + run: pnpm run test:integrations ./integrations/${{ matrix.integration }} + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + + - name: Notify Discord + if: failure() && github.ref == 'refs/heads/next' + uses: discord-actions/message@v2 + with: + webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} + message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `next` branch has failed.' diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts index 3e55fa113..dd93ade64 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -161,7 +161,10 @@ test( }, }, async ({ fs, spawn }) => { - await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch') + let process = await spawn( + 'pnpm tailwindcss --input src/index.css --output dist/out.css --watch', + ) + await process.onStderr((m) => m.includes('Done in')) await fs.expectFileToContain('dist/out.css', [ // @@ -214,7 +217,10 @@ test( }, }, async ({ fs, spawn }) => { - await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch') + let process = await spawn( + 'pnpm tailwindcss --input src/index.css --output dist/out.css --watch', + ) + await process.onStderr((m) => m.includes('Done in')) await fs.expectFileToContain('dist/out.css', [ // @@ -267,7 +273,10 @@ test( }, }, async ({ fs, spawn }) => { - await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch') + let process = await spawn( + 'pnpm tailwindcss --input src/index.css --output dist/out.css --watch', + ) + await process.onStderr((m) => m.includes('Done in')) await fs.expectFileToContain('dist/out.css', [ // diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index c6eac6fa7..a16206171 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -1,7 +1,7 @@ import dedent from 'dedent' import os from 'node:os' import path from 'node:path' -import { describe, expect } from 'vitest' +import { describe } from 'vitest' import { candidate, css, html, js, json, test, ts, yaml } from '../utils' const STANDALONE_BINARY = (() => { @@ -156,9 +156,10 @@ describe.each([ }, }, async ({ root, fs, spawn }) => { - await spawn(`${command} --input src/index.css --output dist/out.css --watch`, { + let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`, { cwd: path.join(root, 'project-a'), }) + await process.onStderr((m) => m.includes('Done in')) await fs.expectFileToContain('project-a/dist/out.css', [ candidate`underline`, @@ -491,7 +492,7 @@ test( 'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]', }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm tailwindcss --input index.css --output dist/out.css') expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` @@ -722,7 +723,7 @@ test( >`, }, }, - async ({ fs, exec, spawn, root }) => { + async ({ fs, exec, spawn, root, expect }) => { await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', { cwd: path.join(root, 'project-a'), }) @@ -790,9 +791,13 @@ test( `) // Watch mode tests - await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch', { - cwd: path.join(root, 'project-a'), - }) + let process = await spawn( + 'pnpm tailwindcss --input src/index.css --output dist/out.css --watch', + { + cwd: path.join(root, 'project-a'), + }, + ) + await process.onStderr((m) => m.includes('Done in')) // Changes to project-a should not be included in the output, we changed the // base folder to project-b. @@ -962,7 +967,7 @@ test( 'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]', }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm tailwindcss --input index.css --output dist/out.css') expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` diff --git a/integrations/package.json b/integrations/package.json index 900679dc5..8023842cd 100644 --- a/integrations/package.json +++ b/integrations/package.json @@ -4,7 +4,6 @@ "private": true, "devDependencies": { "dedent": "1.5.3", - "fast-glob": "^3.3.2", - "kill-port": "^2.0.1" + "fast-glob": "^3.3.2" } } diff --git a/integrations/postcss/config.test.ts b/integrations/postcss/config.test.ts index 30da4219d..11a3c4bd6 100644 --- a/integrations/postcss/config.test.ts +++ b/integrations/postcss/config.test.ts @@ -148,7 +148,8 @@ test( }, }, async ({ fs, spawn }) => { - await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose') + let process = await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose') + await process.onStderr((m) => m.includes('Waiting for file changes')) await fs.expectFileToContain('dist/out.css', [ // @@ -218,7 +219,8 @@ test( }, }, async ({ fs, spawn }) => { - await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose') + let process = await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose') + await process.onStderr((m) => m.includes('Waiting for file changes')) await fs.expectFileToContain('dist/out.css', [ // diff --git a/integrations/postcss/core-as-postcss-plugin.test.ts b/integrations/postcss/core-as-postcss-plugin.test.ts index 85779e7b2..17545fea3 100644 --- a/integrations/postcss/core-as-postcss-plugin.test.ts +++ b/integrations/postcss/core-as-postcss-plugin.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'vitest' +import { describe } from 'vitest' import { css, js, json, test } from '../utils' const variantConfig = { @@ -29,9 +29,9 @@ const variantConfig = { }, } -for (let variant of Object.keys(variantConfig)) { +describe.each(Object.keys(variantConfig))('%s', (variant) => { test( - `can not use \`tailwindcss\` as a postcss module (${variant})`, + `can not use \`tailwindcss\` as a postcss module`, { fs: { ...variantConfig[variant], @@ -47,7 +47,7 @@ for (let variant of Object.keys(variantConfig)) { 'src/index.css': css`@import 'tailwindcss';`, }, }, - async ({ exec }) => { + async ({ exec, expect }) => { expect( exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }), ).rejects.toThrowError( @@ -55,4 +55,4 @@ for (let variant of Object.keys(variantConfig)) { ) }, ) -} +}) diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts index 883bc5615..4da70ee1f 100644 --- a/integrations/postcss/index.test.ts +++ b/integrations/postcss/index.test.ts @@ -1,6 +1,5 @@ import dedent from 'dedent' import path from 'node:path' -import { expect } from 'vitest' import { candidate, css, html, js, json, test, ts, yaml } from '../utils' test( @@ -724,7 +723,7 @@ test( 'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]', }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm postcss index.css --output dist/out.css') expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` @@ -954,7 +953,7 @@ test( `, }, }, - async ({ fs, exec, spawn, root }) => { + async ({ fs, exec, spawn, root, expect }) => { await exec('pnpm postcss src/index.css --output dist/out.css --verbose', { cwd: path.join(root, 'project-a'), }) @@ -1020,7 +1019,6 @@ test( cwd: path.join(root, 'project-a'), }, ) - await process.onStderr((message) => message.includes('Waiting for file changes...')) // Changes to project-a should not be included in the output, we changed the @@ -1215,7 +1213,7 @@ test( 'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]', }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm postcss index.css --output dist/out.css') expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` diff --git a/integrations/postcss/next.test.ts b/integrations/postcss/next.test.ts index b1472c528..b133a9752 100644 --- a/integrations/postcss/next.test.ts +++ b/integrations/postcss/next.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'vitest' +import { describe } from 'vitest' import { candidate, css, fetchStyles, js, json, retryAssertion, test } from '../utils' test( @@ -56,7 +56,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm next build') let files = await fs.glob('.next/static/css/**/*.css') @@ -70,9 +70,10 @@ test( ]) }, ) -;['turbo', 'webpack'].forEach((bundler) => { + +describe.each(['turbo', 'webpack'])('%s', (bundler) => { test( - `dev mode (${bundler})`, + 'dev mode', { fs: { 'package.json': json` @@ -126,26 +127,35 @@ test( `, }, }, - async ({ fs, spawn, getFreePort }) => { - let port = await getFreePort() - await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''} --port ${port}`) + async ({ fs, spawn, expect }) => { + let process = await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''}`) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)/.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await process.onStdout((m) => m.includes('Ready in')) await retryAssertion(async () => { - let css = await fetchStyles(port) + let css = await fetchStyles(url) expect(css).toContain(candidate`underline`) }) - await retryAssertion(async () => { - await fs.write( - 'app/page.js', - js` - export default function Page() { - return

Hello, Next.js!

- } - `, - ) + await fs.write( + 'app/page.js', + js` + export default function Page() { + return

Hello, Next.js!

+ } + `, + ) + await process.onStdout((m) => m.includes('Compiled in')) - let css = await fetchStyles(port) + await retryAssertion(async () => { + let css = await fetchStyles(url) expect(css).toContain(candidate`underline`) expect(css).toContain(candidate`text-red-500`) }) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 42cff58de..e656253a9 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -1,4 +1,3 @@ -import { expect } from 'vitest' import { candidate, css, html, js, json, test, ts } from '../utils' test( @@ -26,7 +25,7 @@ test( 'src/fonts.css': css`/* Unrelated CSS file */`, }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { let output = await exec('npx @tailwindcss/upgrade') expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.') @@ -95,7 +94,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` @@ -202,7 +201,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` @@ -274,7 +273,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -346,7 +345,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -423,7 +422,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -526,7 +525,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -633,7 +632,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain( @@ -704,7 +703,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') let packageJsonContent = await fs.read('package.json') @@ -751,7 +750,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') let packageJsonContent = await fs.read('package.json') @@ -802,7 +801,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) @@ -877,7 +876,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) @@ -944,7 +943,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` @@ -991,7 +990,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` @@ -1035,7 +1034,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -1107,7 +1106,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -1218,7 +1217,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -1352,7 +1351,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { let output = await exec('npx @tailwindcss/upgrade --force') expect(output).toMatch( @@ -1476,7 +1475,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` @@ -1701,7 +1700,7 @@ test( `, }, }, - async ({ exec }) => { + async ({ exec, expect }) => { let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch( (e) => e.toString(), ) @@ -1773,7 +1772,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` @@ -1909,7 +1908,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` @@ -2029,7 +2028,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` @@ -2109,7 +2108,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -2193,7 +2192,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` @@ -2247,7 +2246,7 @@ test( 'tailwind.config.js': js`module.exports = {}`, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') let pkg = JSON.parse(await fs.read('package.json')) @@ -2326,7 +2325,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') // Files should not be modified @@ -2425,7 +2424,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade --force') // Files should not be modified @@ -2532,7 +2531,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade ./src/index.css') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` @@ -2619,7 +2618,7 @@ test( `, }, }, - async ({ exec }) => { + async ({ exec, expect }) => { let output = await exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => e.toString(), ) @@ -2692,7 +2691,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index e0173bcf3..f2643143a 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { describe, expect } from 'vitest' +import { describe } from 'vitest' import { css, html, json, test, ts } from '../utils' test( @@ -151,7 +151,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(` @@ -350,7 +350,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -436,7 +436,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(` @@ -522,7 +522,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -600,7 +600,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -674,7 +674,7 @@ test( `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -784,7 +784,7 @@ test( 'project-b/src/index.html': html`
`, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(` @@ -882,7 +882,7 @@ test( 'backend/mails/welcome.blade.php': html`
`, }, }, - async ({ root, exec, fs }) => { + async ({ root, exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade', { cwd: path.join(root, 'frontend'), }) @@ -950,7 +950,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -1012,7 +1012,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -1074,7 +1074,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -1113,7 +1113,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -1184,7 +1184,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` @@ -1287,7 +1287,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` @@ -1397,7 +1397,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` @@ -1499,7 +1499,7 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs }) => { + async ({ exec, fs, expect }) => { await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` diff --git a/integrations/utils.ts b/integrations/utils.ts index 9e356c089..8f916a3d0 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -1,12 +1,11 @@ import dedent from 'dedent' import fastGlob from 'fast-glob' -import killPort from 'kill-port' import { exec, spawn } from 'node:child_process' import fs from 'node:fs/promises' -import net from 'node:net' import { platform, tmpdir } from 'node:os' import path from 'node:path' -import { test as defaultTest, expect } from 'vitest' +import { stripVTControlCharacters } from 'node:util' +import { test as defaultTest, type ExpectStatic } from 'vitest' const REPO_ROOT = path.join(__dirname, '..') const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) => @@ -35,9 +34,9 @@ interface TestConfig { } interface TestContext { root: string + expect: ExpectStatic exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise spawn(command: string, options?: ChildProcessOptions): Promise - getFreePort(): Promise fs: { write(filePath: string, content: string): Promise create(filePaths: string[]): Promise @@ -54,6 +53,7 @@ interface TestContext { type TestCallback = (context: TestContext) => Promise | void interface TestFlags { only?: boolean + skip?: boolean debug?: boolean } @@ -73,11 +73,17 @@ export function test( name: string, config: TestConfig, testCallback: TestCallback, - { only = false, debug = false }: TestFlags = {}, + { only = false, skip = false, debug = false }: TestFlags = {}, ) { - return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)( + return defaultTest( name, - { timeout: TEST_TIMEOUT, retry: process.env.CI ? 2 : 0 }, + { + timeout: TEST_TIMEOUT, + retry: process.env.CI ? 2 : 0, + only: only || (!process.env.CI && debug), + skip, + concurrent: true, + }, async (options) => { let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT await fs.mkdir(rootDir, { recursive: true }) @@ -92,6 +98,7 @@ export function test( let context = { root, + expect: options.expect, async exec( command: string, childProcessOptions: ChildProcessOptions = {}, @@ -155,7 +162,9 @@ export function test( }) function dispose() { - child.kill() + if (!child.kill()) { + child.kill('SIGKILL') + } let timer = setTimeout( () => @@ -199,14 +208,18 @@ export function test( let content = result.toString() if (debug || only) console.log(content) combined.push(['stdout', content]) - stdoutMessages.push(content) + for (let line of content.split('\n')) { + stdoutMessages.push(stripVTControlCharacters(line)) + } notifyNext(stdoutActors, stdoutMessages) }) child.stderr.on('data', (result) => { let content = result.toString() if (debug || only) console.error(content) combined.push(['stderr', content]) - stderrMessages.push(content) + for (let line of content.split('\n')) { + stderrMessages.push(stripVTControlCharacters(line)) + } notifyNext(stderrActors, stderrMessages) }) child.on('exit', onExit) @@ -246,42 +259,6 @@ export function test( }, } }, - async getFreePort(): Promise { - return new Promise((resolve, reject) => { - let server = net.createServer() - server.listen(0, () => { - let address = server.address() - let port = address === null || typeof address === 'string' ? null : address.port - - server.close(() => { - if (port === null) { - reject(new Error(`Failed to get a free port: address is ${address}`)) - } else { - disposables.push(async () => { - // Wait for 10ms in case the process was just killed - await new Promise((resolve) => setTimeout(resolve, 10)) - - // kill-port uses `lsof` on macOS which is expensive and can - // block for multiple seconds. In order to avoid that for a - // server that is no longer running, we check if the port is - // still in use first. - let isPortTaken = await testIfPortTaken(port) - if (!isPortTaken) { - return - } - - try { - await killPort(port) - } catch { - // If the process can not be killed, we can't do anything - } - }) - resolve(port) - } - }) - }) - }) - }, fs: { async write(filename: string, content: string | Uint8Array): Promise { let full = path.join(root, filename) @@ -374,9 +351,9 @@ export function test( let fileContent = await this.read(filePath) for (let content of Array.isArray(contents) ? contents : [contents]) { if (content instanceof RegExp) { - expect(fileContent).toMatch(content) + options.expect(fileContent).toMatch(content) } else { - expect(fileContent).toContain(content) + options.expect(fileContent).toContain(content) } } }) @@ -385,7 +362,7 @@ export function test( return retryAssertion(async () => { let fileContent = await this.read(filePath) for (let content of contents) { - expect(fileContent).not.toContain(content) + options.expect(fileContent).not.toContain(content) } }) }, @@ -448,6 +425,9 @@ export function test( test.only = (name: string, config: TestConfig, testCallback: TestCallback) => { return test(name, config, testCallback, { only: true }) } +test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => { + return test(name, config, testCallback, { skip: true }) +} test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => { return test(name, config, testCallback, { debug: true }) } @@ -462,16 +442,14 @@ async function overwriteVersionsInPackageJson(content: string): Promise let json = JSON.parse(content) // Resolve all workspace:^ versions to local tarballs - ;['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach( - (key) => { - let dependencies = json[key] || {} - for (let dependency in dependencies) { - if (dependencies[dependency] === 'workspace:^') { - dependencies[dependency] = resolveVersion(dependency) - } + for (let key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + let dependencies = json[key] || {} + for (let dependency in dependencies) { + if (dependencies[dependency] === 'workspace:^') { + dependencies[dependency] = resolveVersion(dependency) } - }, - ) + } + } // Inject transitive dependency overwrite. This is necessary because // @tailwindcss/vite internally depends on a specific version of @@ -505,25 +483,6 @@ export function stripTailwindComment(content: string) { return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim() } -function testIfPortTaken(port: number): Promise { - return new Promise((resolve) => { - let client = new net.Socket() - client.once('connect', () => { - resolve(true) - client.end() - }) - client.once('error', (error: any) => { - if (error.code !== 'ECONNREFUSED') { - resolve(true) - } else { - resolve(false) - } - client.end() - }) - client.connect({ port: port, host: 'localhost' }) - }) -} - export let svg = dedent export let css = dedent export let html = dedent @@ -642,8 +601,12 @@ export async function retryAssertion( throw error } -export async function fetchStyles(port: number, path = '/'): Promise { - let index = await fetch(`http://localhost:${port}${path}`) +export async function fetchStyles(base: string, path = '/'): Promise { + while (base.endsWith('/')) { + base = base.slice(0, -1) + } + + let index = await fetch(`${base}${path}`) let html = await index.text() let linkRegex = / { stylesheets.push( ...(await Promise.all( paths.map(async (path) => { - let css = await fetch(`http://localhost:${port}${path}`, { + let css = await fetch(`${base}${path}`, { headers: { Accept: 'text/css', }, diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts index 188d6e9f4..5b7407e47 100644 --- a/integrations/vite/astro.test.ts +++ b/integrations/vite/astro.test.ts @@ -1,4 +1,3 @@ -import { expect } from 'vitest' import { candidate, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( @@ -35,12 +34,21 @@ test( `, }, }, - async ({ fs, spawn, getFreePort }) => { - let port = await getFreePort() - await spawn(`pnpm astro dev --port ${port}`) + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm astro dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await process.onStdout((m) => m.includes('watching for file changes')) await retryAssertion(async () => { - let css = await fetchStyles(port) + let css = await fetchStyles(url) expect(css).toContain(candidate`underline`) }) @@ -56,7 +64,7 @@ test( `, ) - let css = await fetchStyles(port) + let css = await fetchStyles(url) expect(css).toContain(candidate`underline`) expect(css).toContain(candidate`font-bold`) }) diff --git a/integrations/vite/config.test.ts b/integrations/vite/config.test.ts index 3e0401f9e..3bf560e07 100644 --- a/integrations/vite/config.test.ts +++ b/integrations/vite/config.test.ts @@ -1,4 +1,3 @@ -import { expect } from 'vitest' import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' test( @@ -51,7 +50,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -115,7 +114,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -181,12 +180,19 @@ test( `, }, }, - async ({ fs, getFreePort, spawn }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`) + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) await retryAssertion(async () => { - let css = await fetchStyles(port, '/index.html') + let css = await fetchStyles(url, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: blue') }) @@ -194,7 +200,7 @@ test( await retryAssertion(async () => { await fs.write('my-color.cjs', js`module.exports = 'red'`) - let css = await fetchStyles(port, '/index.html') + let css = await fetchStyles(url, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: red') }) @@ -253,12 +259,19 @@ test( `, }, }, - async ({ fs, getFreePort, spawn }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`) + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) await retryAssertion(async () => { - let css = await fetchStyles(port, '/index.html') + let css = await fetchStyles(url, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: blue') }) @@ -266,7 +279,7 @@ test( await retryAssertion(async () => { await fs.write('my-color.mjs', js`export default 'red'`) - let css = await fetchStyles(port, '/index.html') + let css = await fetchStyles(url, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: red') }) diff --git a/integrations/vite/css-modules.test.ts b/integrations/vite/css-modules.test.ts index 31a4456a5..668281cb4 100644 --- a/integrations/vite/css-modules.test.ts +++ b/integrations/vite/css-modules.test.ts @@ -1,67 +1,65 @@ -import { describe, expect } from 'vitest' +import { describe } from 'vitest' import { css, html, test, ts, txt } from '../utils' -for (let transformer of ['postcss', 'lightningcss']) { - describe(transformer, () => { - test( - `dev mode`, - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } +describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { + test( + `dev mode`, + { + fs: { + 'package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'src/component.ts': ts` - import { foo } from './component.module.css' - let root = document.getElementById('root') - root.className = foo - root.innerText = 'Hello, world!' - `, - 'src/component.module.css': css` - @import 'tailwindcss/utilities'; + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'index.html': html` + + + + +
+ + `, + 'src/component.ts': ts` + import { foo } from './component.module.css' + let root = document.getElementById('root') + root.className = foo + root.innerText = 'Hello, world!' + `, + 'src/component.module.css': css` + @import 'tailwindcss/utilities'; - .foo { - @apply underline; - } - `, - }, + .foo { + @apply underline; + } + `, }, - async ({ exec, fs }) => { - await exec(`pnpm vite build`) + }, + async ({ exec, fs, expect }) => { + await exec(`pnpm vite build`) - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] - await fs.expectFileToContain(filename, [/text-decoration-line: underline;/gi]) - }, - ) - }) -} + await fs.expectFileToContain(filename, [/text-decoration-line: underline;/gi]) + }, + ) +}) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 80a2a50c4..eebe11127 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { describe, expect } from 'vitest' +import { describe } from 'vitest' import { candidate, css, @@ -14,42 +14,189 @@ import { yaml, } from '../utils' -for (let transformer of ['postcss', 'lightningcss']) { - describe(transformer, () => { - test( - `production build`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } +describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { + test( + `production build`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`m-2`, + candidate`flex`, + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + 'dev mode', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/about.html': html` + + + + +
Tailwind Labs
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev', { + cwd: path.join(root, 'project-a'), + }) + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + // Candidates are resolved lazily, so the first visit of index.html + // will only have candidates from this file. + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).not.toContain(candidate`font-bold`) + }) + + // Going to about.html will extend the candidate list to include + // candidates from about.html. + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/about.html') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + }) + + await retryAssertion(async () => { + // Updates are additive and cause new candidates to be added. + await fs.write( + 'project-a/index.html', + html` @@ -57,411 +204,175 @@ for (let transformer of ['postcss', 'lightningcss']) {
Hello, world!
`, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss/theme' theme(reference); - @import 'tailwindcss/utilities'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + }) + + await retryAssertion(async () => { + // Manually added `@source`s are watched and trigger a rebuild + await fs.write( + 'project-b/src/index.js', + js` + const className = "[.changed_&]:content-['project-b/src/index.js']" module.exports = { className } `, - }, - }, - async ({ root, fs, exec }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + ) - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + }) - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`flex`, - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) - - test( - `dev mode`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/about.html': html` - - - - -
Tailwind Labs
- - `, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss/theme' theme(reference); - @import 'tailwindcss/utilities'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, spawn, getFreePort, fs }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`, { - cwd: path.join(root, 'project-a'), - }) - - // Candidates are resolved lazily, so the first visit of index.html - // will only have candidates from this file. - await retryAssertion(async () => { - let styles = await fetchStyles(port, '/index.html') - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).not.toContain(candidate`font-bold`) - }) - - // Going to about.html will extend the candidate list to include - // candidates from about.html. - await retryAssertion(async () => { - let styles = await fetchStyles(port, '/about.html') - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - }) - - await retryAssertion(async () => { - // Updates are additive and cause new candidates to be added. - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- - `, - ) - - let styles = await fetchStyles(port) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`m-2`) - }) - - await retryAssertion(async () => { - // Manually added `@source`s are watched and trigger a rebuild - await fs.write( - 'project-b/src/index.js', - js` - const className = "[.changed_&]:content-['project-b/src/index.js']" - module.exports = { className } - `, - ) - - let styles = await fetchStyles(port) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - }) - - await retryAssertion(async () => { - // After updates to the CSS file, all previous candidates should still be in - // the generated CSS - await fs.write( - 'project-a/src/index.css', - css` - ${await fs.read('project-a/src/index.css')} - - .red { - color: red; - } - `, - ) - - let styles = await fetchStyles(port) - expect(styles).toContain(candidate`red`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - expect(styles).toContain(candidate`font-bold`) - }) - }, - ) - - test( - 'watch mode', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss/theme' theme(reference); - @import 'tailwindcss/utilities'; - @import './custom-theme.css'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-a/src/custom-theme.css': css` - /* Will be overwritten later */ - @theme { - --color-primary: black; - } - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, spawn, fs }) => { - await spawn(`pnpm vite build --watch`, { - cwd: path.join(root, 'project-a'), - }) - - let filename = '' - await retryAssertion(async () => { - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - filename = files[0][0] - }) - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`flex`, + await retryAssertion(async () => { + // After updates to the CSS file, all previous candidates should still be in + // the generated CSS + await fs.write( + 'project-a/src/index.css', css` - .text-primary { - color: var(--color-primary); + ${await fs.read('project-a/src/index.css')} + + .red { + color: red; } `, - ]) + ) - await retryAssertion(async () => { - await fs.write( - 'project-a/src/custom-theme.css', - css` - /* Overriding the primary color */ - @theme { - --color-primary: red; - } - `, - ) + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`red`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + expect(styles).toContain(candidate`font-bold`) + }) + }, + ) - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - - expect(styles).toContain(css` - .text-primary { - color: var(--color-primary); + test( + 'watch mode', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" } - `) - }) + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - await retryAssertion(async () => { - // Updates are additive and cause new candidates to be added. - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - }) - - await retryAssertion(async () => { - // Manually added `@source`s are watched and trigger a rebuild - await fs.write( - 'project-b/src/index.js', - js` - const className = "[.changed_&]:content-['project-b/src/index.js']" - module.exports = { className } - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - }) - - await retryAssertion(async () => { - // After updates to the CSS file, all previous candidates should still be in - // the generated CSS - await fs.write( - 'project-a/src/index.css', - css` - ${await fs.read('project-a/src/index.css')} - - .red { - color: red; - } - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - expect(styles).toContain(candidate`red`) - }) + export default defineConfig({ + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @import './custom-theme.css'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-a/src/custom-theme.css': css` + /* Will be overwritten later */ + @theme { + --color-primary: black; + } + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, }, - ) + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite build --watch', { + cwd: path.join(root, 'project-a'), + }) + await process.onStdout((m) => m.includes('built in')) - test( - `source(none) disables looking at the module graph`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } + let filename = '' + await retryAssertion(async () => { + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + filename = files[0][0] + }) + + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`flex`, + css` + .text-primary { + color: var(--color-primary); + } + `, + ]) + + await retryAssertion(async () => { + await fs.write( + 'project-a/src/custom-theme.css', + css` + /* Overriding the primary color */ + @theme { + --color-primary: red; } `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + ) - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [, styles] = files[0] + + expect(styles).toContain(css` + .text-primary { + color: var(--color-primary); + } + `) + }) + + await retryAssertion(async () => { + // Updates are additive and cause new candidates to be added. + await fs.write( + 'project-a/index.html', + html` @@ -469,225 +380,320 @@ for (let transformer of ['postcss', 'lightningcss']) {
Hello, world!
`, - 'project-a/src/index.css': css` - @import 'tailwindcss' source(none); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, fs, exec }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + ) let files = await fs.glob('project-a/dist/**/*.css') expect(files).toHaveLength(1) - let [filename] = files[0] + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + }) - // `underline` and `m-2` are only present from files in the module graph - // which we've explicitly disabled with source(none) so they should not - // be present - await fs.expectFileNotToContain(filename, [ - // - candidate`underline`, - candidate`m-2`, - ]) - - // The files from `project-b` should be included because there is an - // explicit `@source` directive for it - await fs.expectFileToContain(filename, [ - // - candidate`flex`, - ]) - - // The explicit source directive only covers HTML files, so the JS file - // should not be included - await fs.expectFileNotToContain(filename, [ - // - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) - - test( - `source("…") filters the module graph`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - - `, - 'project-a/app/index.js': js` - const className = "content-['project-a/app/index.js']" - export default { className } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss' source('../app'); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" + await retryAssertion(async () => { + // Manually added `@source`s are watched and trigger a rebuild + await fs.write( + 'project-b/src/index.js', + js` + const className = "[.changed_&]:content-['project-b/src/index.js']" module.exports = { className } `, - }, - }, - async ({ root, fs, exec }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + ) let files = await fs.glob('project-a/dist/**/*.css') expect(files).toHaveLength(1) - let [filename] = files[0] + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + }) - // `underline` and `m-2` are present in files in the module graph but - // we've filtered the module graph such that we only look in - // `./app/**/*` so they should not be present - await fs.expectFileNotToContain(filename, [ - // - candidate`underline`, - candidate`m-2`, - candidate`content-['project-a/index.html']`, - ]) + await retryAssertion(async () => { + // After updates to the CSS file, all previous candidates should still be in + // the generated CSS + await fs.write( + 'project-a/src/index.css', + css` + ${await fs.read('project-a/src/index.css')} - // We've filtered the module graph to only look in ./app/**/* so the - // candidates from that project should be present - await fs.expectFileToContain(filename, [ - // - candidate`content-['project-a/app/index.js']`, - ]) - - // Even through we're filtering the module graph explicit sources are - // additive and as such files from `project-b` should be included - // because there is an explicit `@source` directive for it - await fs.expectFileToContain(filename, [ - // - candidate`content-['project-b/src/index.html']`, - ]) - - // The explicit source directive only covers HTML files, so the JS file - // should not be included - await fs.expectFileNotToContain(filename, [ - // - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) - - test( - `source("…") must be a directory`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} - "vite": "^6" - } + .red { + color: red; } `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - - `, - 'project-a/app/index.js': js` - const className = "content-['project-a/app/index.js']" - export default { className } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss' source('../i-do-not-exist'); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, fs, exec }) => { - await expect(() => - exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), - ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') + ) let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(0) + expect(files).toHaveLength(1) + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + expect(styles).toContain(candidate`red`) + }) + }, + ) + + test( + `source(none) disables looking at the module graph`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source(none); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, }, - ) - }) -} + }, + async ({ root, fs, exec, expect }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + // `underline` and `m-2` are only present from files in the module graph + // which we've explicitly disabled with source(none) so they should not + // be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + ]) + + // The files from `project-b` should be included because there is an + // explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`flex`, + ]) + + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + `source("…") filters the module graph`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../app'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + // `underline` and `m-2` are present in files in the module graph but + // we've filtered the module graph such that we only look in + // `./app/**/*` so they should not be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + candidate`content-['project-a/index.html']`, + ]) + + // We've filtered the module graph to only look in ./app/**/* so the + // candidates from that project should be present + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-a/app/index.js']`, + ]) + + // Even through we're filtering the module graph explicit sources are + // additive and as such files from `project-b` should be included + // because there is an explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-b/src/index.html']`, + ]) + + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + `source("…") must be a directory`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../i-do-not-exist'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await expect(() => + exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), + ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(0) + }, + ) +}) test( `demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`, @@ -733,14 +739,21 @@ test( 'src/index.css': css`@import 'tailwindcss';`, }, }, - async ({ spawn, getFreePort, fs }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`) + async ({ spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) // Candidates are resolved lazily, so the first visit of index.html // will only have candidates from this file. await retryAssertion(async () => { - let styles = await fetchStyles(port, '/index.html') + let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`underline`) expect(styles).not.toContain(candidate`font-bold`) }) @@ -748,7 +761,7 @@ test( // Going to about.html will extend the candidate list to include // candidates from about.html. await retryAssertion(async () => { - let styles = await fetchStyles(port, '/about.html') + let styles = await fetchStyles(url, '/about.html') expect(styles).toContain(candidate`underline`) expect(styles).toContain(candidate`font-bold`) }) @@ -757,7 +770,7 @@ test( // We change the CSS file so it is no longer a valid Tailwind root. await fs.write('src/index.css', css`@import 'tailwindcss';`) - let styles = await fetchStyles(port) + let styles = await fetchStyles(url) expect(styles).toContain(candidate`underline`) expect(styles).toContain(candidate`font-bold`) }) @@ -801,18 +814,25 @@ test( 'src/index.css': css`@import 'tailwindcss';`, }, }, - async ({ spawn, getFreePort }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`) + async ({ spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let baseUrl = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) baseUrl = match[1] + return Boolean(baseUrl) + }) await retryAssertion(async () => { // We have to load the .js file first so that the static assets are // resolved - await fetch(`http://localhost:${port}/src/index.js`).then((r) => r.text()) + await fetch(`${baseUrl}/src/index.js`).then((r) => r.text()) let [raw, url] = await Promise.all([ - fetch(`http://localhost:${port}/src/index.css?raw`).then((r) => r.text()), - fetch(`http://localhost:${port}/src/index.css?url`).then((r) => r.text()), + fetch(`${baseUrl}/src/index.css?raw`).then((r) => r.text()), + fetch(`${baseUrl}/src/index.css?url`).then((r) => r.text()), ]) expect(firstLine(raw)).toBe(`export default "@import 'tailwindcss';"`) diff --git a/integrations/vite/multi-root.test.ts b/integrations/vite/multi-root.test.ts index d2d59f50e..39da43690 100644 --- a/integrations/vite/multi-root.test.ts +++ b/integrations/vite/multi-root.test.ts @@ -1,4 +1,3 @@ -import { expect } from 'vitest' import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( @@ -65,7 +64,7 @@ test( `, }, }, - async ({ fs, exec }) => { + async ({ fs, exec, expect }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -86,7 +85,7 @@ test( ) test( - `dev mode`, + 'dev mode', { fs: { 'package.json': json` @@ -141,14 +140,21 @@ test( `, }, }, - async ({ root, spawn, getFreePort, fs }) => { - let port = await getFreePort() - await spawn(`pnpm vite dev --port ${port}`) + async ({ spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) // Candidates are resolved lazily, so the first visit of index.html // will only have candidates from this file. await retryAssertion(async () => { - let styles = await fetchStyles(port, '/root1.html') + let styles = await fetchStyles(url, '/root1.html') expect(styles).toContain(candidate`one:underline`) expect(styles).not.toContain(candidate`two:underline`) }) @@ -156,7 +162,7 @@ test( // Going to about.html will extend the candidate list to include // candidates from about.html. await retryAssertion(async () => { - let styles = await fetchStyles(port, '/root2.html') + let styles = await fetchStyles(url, '/root2.html') expect(styles).not.toContain(candidate`one:underline`) expect(styles).toContain(candidate`two:underline`) }) diff --git a/integrations/vite/nuxt.test.ts b/integrations/vite/nuxt.test.ts index cfd521e48..1b834525c 100644 --- a/integrations/vite/nuxt.test.ts +++ b/integrations/vite/nuxt.test.ts @@ -1,4 +1,3 @@ -import { expect } from 'vitest' import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' const SETUP = { @@ -37,12 +36,25 @@ const SETUP = { }, } -test('dev mode', SETUP, async ({ fs, spawn, getFreePort }) => { - let port = await getFreePort() - await spawn(`pnpm nuxt dev --port ${port}`) +test('dev mode', SETUP, async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm nuxt dev', { + env: { + TEST: 'false', // VERY IMPORTANT OTHERWISE YOU WON'T GET OUTPUT + NODE_ENV: 'development', + }, + }) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await process.onStdout((m) => m.includes('server warmed up in')) await retryAssertion(async () => { - let css = await fetchStyles(port) + let css = await fetchStyles(url) expect(css).toContain(candidate`underline`) }) @@ -50,29 +62,36 @@ test('dev mode', SETUP, async ({ fs, spawn, getFreePort }) => { await fs.write( 'app.vue', html` -