Skip to content

Commit 27912f9

Browse files
Add integration test setup and tests for the Vite integration (#14089)
This PR adds a new root `/integrations` folder that will be the home of integration tests. The idea of these tests is to use Tailwind in various setups just like our users would (by only using the publishable npm builds). To avoid issues with concurrent tests making changes to the file system, to make it very easy to test through a range of versions, and to avoid changing configuration objects over and over in test runs, we decided to inline the scaffolding completely into the test file and have no examples checked into the repo. Here's an example of how this can look like for a simple Vite test: ```ts test('works with production builds', { fs: { 'package.json': json` { "type": "module", "dependencies": { "@tailwindcss/vite": "workspace:^", "tailwindcss": "workspace:^" }, "devDependencies": { "vite": "^5.3.5" } } `, 'vite.config.ts': ts` import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' export default defineConfig({ build: { cssMinify: false }, plugins: [tailwindcss()], }) `, 'index.html': html` <head> <link rel="stylesheet" href="./src/index.css"> </head> <body> <div class="underline m-2">Hello, world!</div> </body> `, 'src/index.css': css` @import 'tailwindcss/theme' reference; @import 'tailwindcss/utilities'; `, }, }, async ({ fs, exec }) => { await exec('pnpm vite build') expect.assertions(2) for (let [path, content] of await fs.glob('dist/**/*.css')) { expect(path).toMatch(/\.css$/) expect(stripTailwindComment(content)).toMatchInlineSnapshot( ` ".m-2 { margin: var(--spacing-2, .5rem); } .underline { text-decoration-line: underline; }" `, ) } }, ) ``` By defining all dependencies this way, we never have to worry about which fixtures are checked in and can more easily describe changes to the setup. For ergonomics, we've also added the [`embed` prettier plugin](https://github.com/Sec-ant/prettier-plugin-embed). This will mean that files inlined in the `fs` setup are properly indented. No extra work needed! If you're using VS Code, I can also recommend the [Language Literals](https://marketplace.visualstudio.com/items?itemName=sissel.language-literals) extension so that syntax highlighting also _just works_. A neat feature of inlining the scaffolding like this is to make it very simple to test through a variety of versions. For example, here's how we can set up a test against Vite 5 and Vite 4: ```js ;['^4.5.3', '^5.3.5'].forEach(viteVersion => { test(`works with production builds for Vite ${viteVersion}`, { fs: { 'package.json': json` { "type": "module", "devDependencies": { "vite": "${viteVersion}" } } `, async () => { // Do something }, ) }) ``` ## Philosophy Before we dive into the specifics, I want to clearly state the design considerations we have chosen for this new test suite: - All file mutations should be done in temp folders, nothing should ever mess with your working directory - Windows as a first-class citizen - Have a clean and simple API that describes the test setup only using public APIs - Focus on reliability (make sure cleanup scripts work and are tolerant to various error scenarios) - If a user reports an issue with a specific configuration, we want to be able to reproduce them with integration tests, no matter how obscure the setup (this means the test need to be in control of most of the variables) - Tests should be reasonably fast (obviously this depends on the integration. If we use a slow build tool, we can't magically speed it up, but our overhead should be minimal). ## How it works The current implementation provides a custom `test` helper function that, when used, sets up the environment according to the configuration. It'll create a new temporary directory and create all files, ensuring things like proper `\r\n` line endings on Windows. We do have to patch the `package.json` specifically, since we can not use public versions of the tailwindcss packages as we want to be able to test against a development build. To make this happen, every `pnpm build` run now creates tarballs of the npm modules (that contain only the files that would also in the published build). We then patch the `package.json` to rewrite `workspace:^` versions to link to those tarballs. We found this to work reliably on Windows and macOS as well as being fast enough to not cause any issues. Furthermore we also decided to use `pnpm` as the version manager for integration tests because of it's global module cache (so installing `vite` is fast as soon as you installed it once). The test function will receive a few utilities that it can use to more easily interact with the temp dir. One example is a `fs.glob` function that you can use to easily find files in eventual `dist/` directories or helpers around `spawn` and `exec` that make sure that processes are cleaned up correctly. Because we use tarballs from our build dependencies, working on changes requires a workflow where you run `pnpm build` before running `pnpm test:integrations`. However it also means we can run clients like our CLI client with no additional overhead—just install the dependency like any user would and set up your test cases this way. ## Test plan This PR also includes two Vite specific integration tests: One testing a static build (`pnpm vite build`) and one a dev mode build (`pnpm vite dev`) that also makes changes to the file system and asserts that the resources properly update. --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 2667271 commit 27912f9

File tree

9 files changed

+639
-6
lines changed

9 files changed

+639
-6
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
- name: Test
6969
run: pnpm run test
7070

71+
- name: Integration Tests
72+
run: pnpm run test:integrations
73+
7174
- name: Install Playwright Browsers
7275
run: npx playwright install --with-deps
7376

integrations/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "internal-integrations",
3+
"version": "0.0.0",
4+
"private": true,
5+
"devDependencies": {
6+
"dedent": "1.5.3",
7+
"fast-glob": "^3.3.2",
8+
"kill-port": "^2.0.1"
9+
}
10+
}

integrations/utils.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import dedent from 'dedent'
2+
import fastGlob from 'fast-glob'
3+
import killPort from 'kill-port'
4+
import { execSync, spawn } from 'node:child_process'
5+
import fs from 'node:fs/promises'
6+
import net from 'node:net'
7+
import { homedir, platform, tmpdir } from 'node:os'
8+
import path from 'node:path'
9+
import { test as defaultTest } from 'vitest'
10+
11+
export let css = dedent
12+
export let html = dedent
13+
export let ts = dedent
14+
export let json = dedent
15+
16+
const REPO_ROOT = path.join(__dirname, '..')
17+
18+
interface SpawnedProcess {
19+
dispose: () => void
20+
onStdout: (predicate: (message: string) => boolean) => Promise<void>
21+
onStderr: (predicate: (message: string) => boolean) => Promise<void>
22+
}
23+
24+
interface TestConfig {
25+
fs: {
26+
[filePath: string]: string
27+
}
28+
}
29+
interface TestContext {
30+
exec(command: string): Promise<string>
31+
spawn(command: string): Promise<SpawnedProcess>
32+
getFreePort(): Promise<number>
33+
fs: {
34+
write(filePath: string, content: string): Promise<void>
35+
glob(pattern: string): Promise<[string, string][]>
36+
}
37+
}
38+
type TestCallback = (context: TestContext) => Promise<void> | void
39+
40+
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
41+
42+
export function test(
43+
name: string,
44+
config: TestConfig,
45+
testCallback: TestCallback,
46+
{ only = false } = {},
47+
) {
48+
return (only ? defaultTest.only : defaultTest)(name, { timeout: 30000 }, async (options) => {
49+
let root = await fs.mkdtemp(
50+
// On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder
51+
// that apparently causes the vite builds to not work.
52+
path.join(
53+
process.env.CI && platform() === 'win32' ? homedir() : tmpdir(),
54+
'tailwind-integrations',
55+
),
56+
)
57+
58+
async function write(filename: string, content: string): Promise<void> {
59+
let full = path.join(root, filename)
60+
61+
if (filename.endsWith('package.json')) {
62+
content = overwriteVersionsInPackageJson(content)
63+
}
64+
65+
// Ensure that files written on Windows use \r\n line ending
66+
if (platform() === 'win32') {
67+
content = content.replace(/\n/g, '\r\n')
68+
}
69+
70+
let dir = path.dirname(full)
71+
await fs.mkdir(dir, { recursive: true })
72+
await fs.writeFile(full, content)
73+
}
74+
75+
for (let [filename, content] of Object.entries(config.fs)) {
76+
await write(filename, content)
77+
}
78+
79+
try {
80+
execSync('pnpm install', { cwd: root })
81+
} catch (error: any) {
82+
console.error(error.stdout.toString())
83+
console.error(error.stderr.toString())
84+
throw error
85+
}
86+
87+
let disposables: (() => Promise<void>)[] = []
88+
async function dispose() {
89+
await Promise.all(disposables.map((dispose) => dispose()))
90+
await fs.rm(root, { recursive: true, maxRetries: 3, force: true })
91+
}
92+
options.onTestFinished(dispose)
93+
94+
let context = {
95+
async exec(command: string) {
96+
return execSync(command, { cwd: root }).toString()
97+
},
98+
async spawn(command: string) {
99+
let resolveDisposal: (() => void) | undefined
100+
let rejectDisposal: ((error: Error) => void) | undefined
101+
let disposePromise = new Promise<void>((resolve, reject) => {
102+
resolveDisposal = resolve
103+
rejectDisposal = reject
104+
})
105+
106+
let child = spawn(command, {
107+
cwd: root,
108+
shell: true,
109+
env: {
110+
...process.env,
111+
},
112+
})
113+
114+
function dispose() {
115+
child.kill()
116+
117+
let timer = setTimeout(
118+
() => rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
119+
1000,
120+
)
121+
disposePromise.finally(() => clearTimeout(timer))
122+
return disposePromise
123+
}
124+
disposables.push(dispose)
125+
function onExit() {
126+
resolveDisposal?.()
127+
}
128+
129+
let stdoutMessages: string[] = []
130+
let stderrMessages: string[] = []
131+
132+
let stdoutActors: SpawnActor[] = []
133+
let stderrActors: SpawnActor[] = []
134+
135+
function notifyNext(actors: SpawnActor[], messages: string[]) {
136+
if (actors.length <= 0) return
137+
let [next] = actors
138+
139+
for (let [idx, message] of messages.entries()) {
140+
if (next.predicate(message)) {
141+
messages.splice(0, idx + 1)
142+
let actorIdx = actors.indexOf(next)
143+
actors.splice(actorIdx, 1)
144+
next.resolve()
145+
break
146+
}
147+
}
148+
}
149+
150+
child.stdout.on('data', (result) => {
151+
stdoutMessages.push(result.toString())
152+
notifyNext(stdoutActors, stdoutMessages)
153+
})
154+
child.stderr.on('data', (result) => {
155+
stderrMessages.push(result.toString())
156+
notifyNext(stderrActors, stderrMessages)
157+
})
158+
child.on('exit', onExit)
159+
child.on('error', (error) => {
160+
if (error.name !== 'AbortError') {
161+
throw error
162+
}
163+
})
164+
165+
options.onTestFailed(() => {
166+
stdoutMessages.map((message) => console.log(message))
167+
stderrMessages.map((message) => console.error(message))
168+
})
169+
170+
return {
171+
dispose,
172+
onStdout(predicate: (message: string) => boolean) {
173+
return new Promise<void>((resolve) => {
174+
stdoutActors.push({ predicate, resolve })
175+
notifyNext(stdoutActors, stdoutMessages)
176+
})
177+
},
178+
onStderr(predicate: (message: string) => boolean) {
179+
return new Promise<void>((resolve) => {
180+
stderrActors.push({ predicate, resolve })
181+
notifyNext(stderrActors, stderrMessages)
182+
})
183+
},
184+
}
185+
},
186+
async getFreePort(): Promise<number> {
187+
return new Promise((resolve, reject) => {
188+
let server = net.createServer()
189+
server.listen(0, () => {
190+
let address = server.address()
191+
let port = address === null || typeof address === 'string' ? null : address.port
192+
193+
server.close(() => {
194+
if (port === null) {
195+
reject(new Error(`Failed to get a free port: address is ${address}`))
196+
} else {
197+
disposables.push(async () => {
198+
// Wait for 10ms in case the process was just killed
199+
await new Promise((resolve) => setTimeout(resolve, 10))
200+
201+
// kill-port uses `lsof` on macOS which is expensive and can
202+
// block for multiple seconds. In order to avoid that for a
203+
// server that is no longer running, we check if the port is
204+
// still in use first.
205+
let isPortTaken = await testIfPortTaken(port)
206+
if (!isPortTaken) {
207+
return
208+
}
209+
210+
await killPort(port)
211+
})
212+
resolve(port)
213+
}
214+
})
215+
})
216+
})
217+
},
218+
fs: {
219+
write,
220+
async glob(pattern: string) {
221+
let files = await fastGlob(pattern, { cwd: root })
222+
return Promise.all(
223+
files.map(async (file) => {
224+
let content = await fs.readFile(path.join(root, file), 'utf8')
225+
return [file, content]
226+
}),
227+
)
228+
},
229+
},
230+
} satisfies TestContext
231+
232+
await testCallback(context)
233+
})
234+
}
235+
test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
236+
return test(name, config, testCallback, { only: true })
237+
}
238+
239+
// Maps package names to their tarball filenames. See scripts/pack-packages.ts
240+
// for more details.
241+
function pkgToFilename(name: string) {
242+
return `${name.replace('@', '').replace('/', '-')}.tgz`
243+
}
244+
245+
function overwriteVersionsInPackageJson(content: string): string {
246+
let json = JSON.parse(content)
247+
248+
// Resolve all workspace:^ versions to local tarballs
249+
;['dependencies', 'devDependencies', 'peerDependencies'].forEach((key) => {
250+
let dependencies = json[key] || {}
251+
for (let dependency in dependencies) {
252+
if (dependencies[dependency] === 'workspace:^') {
253+
dependencies[dependency] = resolveVersion(dependency)
254+
}
255+
}
256+
})
257+
258+
// Inject transitive dependency overwrite. This is necessary because
259+
// @tailwindcss/vite internally depends on a specific version of
260+
// @tailwindcss/oxide and we instead want to resolve it to the locally built
261+
// version.
262+
json.pnpm ||= {}
263+
json.pnpm.overrides ||= {}
264+
json.pnpm.overrides['@tailwindcss/oxide'] = resolveVersion('@tailwindcss/oxide')
265+
266+
return JSON.stringify(json, null, 2)
267+
}
268+
269+
function resolveVersion(dependency: string) {
270+
let tarball = path.join(REPO_ROOT, 'dist', pkgToFilename(dependency))
271+
return `file:${tarball}`
272+
}
273+
274+
export function stripTailwindComment(content: string) {
275+
return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim()
276+
}
277+
278+
function testIfPortTaken(port: number): Promise<boolean> {
279+
return new Promise((resolve) => {
280+
let client = new net.Socket()
281+
client.once('connect', () => {
282+
resolve(true)
283+
client.end()
284+
})
285+
client.once('error', (error: any) => {
286+
if (error.code !== 'ECONNREFUSED') {
287+
resolve(true)
288+
} else {
289+
resolve(false)
290+
}
291+
client.end()
292+
})
293+
client.connect({ port: port, host: 'localhost' })
294+
})
295+
}

0 commit comments

Comments
 (0)