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' const REPO_ROOT = path.join(__dirname, '..') const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) => name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''), ) interface SpawnedProcess { dispose: () => void onStdout: (predicate: (message: string) => boolean) => Promise onStderr: (predicate: (message: string) => boolean) => Promise } interface ChildProcessOptions { cwd?: string } interface ExecOptions { ignoreStdErr?: boolean } interface TestConfig { fs: { [filePath: string]: string } } interface TestContext { root: string exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise spawn(command: string, options?: ChildProcessOptions): Promise getFreePort(): Promise fs: { write(filePath: string, content: string): Promise read(filePath: string): Promise glob(pattern: string): Promise<[string, string][]> expectFileToContain( filePath: string, contents: string | string[] | RegExp | RegExp[], ): Promise expectFileNotToContain(filePath: string, contents: string | string[]): Promise } } type TestCallback = (context: TestContext) => Promise | void interface TestFlags { only?: boolean debug?: boolean } type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void } const IS_WINDOWS = platform() === 'win32' const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000 const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000 // On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder that // apparently causes the vite builds to not work. const TMP_ROOT = process.env.CI && IS_WINDOWS ? path.dirname(process.env.GITHUB_WORKSPACE!) : tmpdir() export function test( name: string, config: TestConfig, testCallback: TestCallback, { only = false, debug = false }: TestFlags = {}, ) { return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)( name, { timeout: TEST_TIMEOUT }, async (options) => { let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT await fs.mkdir(rootDir, { recursive: true }) let root = await fs.mkdtemp(path.join(rootDir, 'tailwind-integrations')) if (debug) { console.log('Running test in debug mode. File system will be written to:') console.log(root) console.log() } let context = { root, async exec( command: string, childProcessOptions: ChildProcessOptions = {}, execOptions: ExecOptions = {}, ) { let cwd = childProcessOptions.cwd ?? root if (debug && cwd !== root) { let relative = path.relative(root, cwd) if (relative[0] !== '.') relative = `./${relative}` console.log(`> cd ${relative}`) } if (debug) console.log(`> ${command}`) return new Promise((resolve, reject) => { exec( command, { cwd, ...childProcessOptions, }, (error, stdout, stderr) => { if (error) { if (execOptions.ignoreStdErr !== true) console.error(stderr) reject(error) } else { resolve(stdout.toString()) } }, ) }) }, async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) { let resolveDisposal: (() => void) | undefined let rejectDisposal: ((error: Error) => void) | undefined let disposePromise = new Promise((resolve, reject) => { resolveDisposal = resolve rejectDisposal = reject }) let cwd = childProcessOptions.cwd ?? root if (debug && cwd !== root) { let relative = path.relative(root, cwd) if (relative[0] !== '.') relative = `./${relative}` console.log(`> cd ${relative}`) } if (debug) console.log(`>& ${command}`) let child = spawn(command, { cwd, shell: true, env: { ...process.env, }, ...childProcessOptions, }) function dispose() { child.kill() let timer = setTimeout( () => rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)), ASSERTION_TIMEOUT, ) disposePromise.finally(() => { clearTimeout(timer) }) return disposePromise } disposables.push(dispose) function onExit() { resolveDisposal?.() } let stdoutMessages: string[] = [] let stderrMessages: string[] = [] let stdoutActors: SpawnActor[] = [] let stderrActors: SpawnActor[] = [] function notifyNext(actors: SpawnActor[], messages: string[]) { if (actors.length <= 0) return let [next] = actors for (let [idx, message] of messages.entries()) { if (next.predicate(message)) { messages.splice(0, idx + 1) let actorIdx = actors.indexOf(next) actors.splice(actorIdx, 1) next.resolve() break } } } let combined: ['stdout' | 'stderr', string][] = [] child.stdout.on('data', (result) => { let content = result.toString() if (debug) console.log(content) combined.push(['stdout', content]) stdoutMessages.push(content) notifyNext(stdoutActors, stdoutMessages) }) child.stderr.on('data', (result) => { let content = result.toString() if (debug) console.error(content) combined.push(['stderr', content]) stderrMessages.push(content) notifyNext(stderrActors, stderrMessages) }) child.on('exit', onExit) child.on('error', (error) => { if (error.name !== 'AbortError') { throw error } }) options.onTestFailed(() => { // In debug mode, messages are logged to the console immediately if (debug) return for (let [type, message] of combined) { if (type === 'stdout') { console.log(message) } else { console.error(message) } } }) return { dispose, onStdout(predicate: (message: string) => boolean) { return new Promise((resolve) => { stdoutActors.push({ predicate, resolve }) notifyNext(stdoutActors, stdoutMessages) }) }, onStderr(predicate: (message: string) => boolean) { return new Promise((resolve) => { stderrActors.push({ predicate, resolve }) notifyNext(stderrActors, stderrMessages) }) }, } }, 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): Promise { let full = path.join(root, filename) if (filename.endsWith('package.json')) { content = await overwriteVersionsInPackageJson(content) } // Ensure that files written on Windows use \r\n line ending if (IS_WINDOWS) { content = content.replace(/\n/g, '\r\n') } let dir = path.dirname(full) await fs.mkdir(dir, { recursive: true }) await fs.writeFile(full, content) }, async read(filePath: string) { let content = await fs.readFile(path.resolve(root, filePath), 'utf8') // Ensure that files read on Windows have \r\n line endings removed if (IS_WINDOWS) { content = content.replace(/\r\n/g, '\n') } return content }, async glob(pattern: string) { let files = await fastGlob(pattern, { cwd: root }) return Promise.all( files.map(async (file) => { let content = await fs.readFile(path.join(root, file), 'utf8') return [file, content] }), ) }, async expectFileToContain(filePath, contents) { return retryAssertion(async () => { let fileContent = await this.read(filePath) for (let content of Array.isArray(contents) ? contents : [contents]) { if (content instanceof RegExp) { expect(fileContent).toMatch(content) } else { expect(fileContent).toContain(content) } } }) }, async expectFileNotToContain(filePath, contents) { return retryAssertion(async () => { let fileContent = await this.read(filePath) for (let content of contents) { expect(fileContent).not.toContain(content) } }) }, }, } satisfies TestContext config.fs['.gitignore'] ??= txt` node_modules/ ` for (let [filename, content] of Object.entries(config.fs)) { await context.fs.write(filename, content) } try { // In debug mode, the directory is going to be inside the pnpm workspace // of the tailwindcss package. This means that `pnpm install` will run // pnpm install on the workspace instead (expect if the root dir defines // a separate workspace). We work around this by using the // `--ignore-workspace` flag. let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml'] await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`) } catch (error: any) { console.error(error) console.error(error.stdout?.toString()) console.error(error.stderr?.toString()) throw error } let disposables: (() => Promise)[] = [] async function dispose() { await Promise.all(disposables.map((dispose) => dispose())) if (!debug) { await gracefullyRemove(root) } } options.onTestFinished(dispose) return await testCallback(context) }, ) } test.only = (name: string, config: TestConfig, testCallback: TestCallback) => { return test(name, config, testCallback, { only: true }) } test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => { return test(name, config, testCallback, { debug: true }) } // Maps package names to their tarball filenames. See scripts/pack-packages.ts // for more details. function pkgToFilename(name: string) { return `${name.replace('@', '').replace('/', '-')}.tgz` } 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) } } }, ) // Inject transitive dependency overwrite. This is necessary because // @tailwindcss/vite internally depends on a specific version of // @tailwindcss/oxide and we instead want to resolve it to the locally built // version. json.pnpm ||= {} json.pnpm.overrides ||= {} for (let pkg of PUBLIC_PACKAGES) { json.pnpm.overrides[pkg] = resolveVersion(pkg) } return JSON.stringify(json, null, 2) } function resolveVersion(dependency: string) { let tarball = path.join(REPO_ROOT, 'dist', pkgToFilename(dependency)) return `file:${tarball}` } 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 css = dedent export let html = dedent export let ts = dedent export let js = dedent export let json = dedent export let yaml = dedent export let txt = dedent export function candidate(strings: TemplateStringsArray, ...values: any[]) { let output: string[] = [] for (let i = 0; i < strings.length; i++) { output.push(strings[i]) if (i < values.length) { output.push(values[i]) } } return `.${escape(output.join('').trim())}` } // https://drafts.csswg.org/cssom/#serialize-an-identifier export function escape(value: string) { if (arguments.length == 0) { throw new TypeError('`CSS.escape` requires an argument.') } var string = String(value) var length = string.length var index = -1 var codeUnit var result = '' var firstCodeUnit = string.charCodeAt(0) if ( // If the character is the first character and is a `-` (U+002D), and // there is no second character, […] length == 1 && firstCodeUnit == 0x002d ) { return '\\' + string } while (++index < length) { codeUnit = string.charCodeAt(index) // Note: there’s no need to special-case astral symbols, surrogate // pairs, or lone surrogates. // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER // (U+FFFD). if (codeUnit == 0x0000) { result += '\uFFFD' continue } if ( // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is // U+007F, […] (codeUnit >= 0x0001 && codeUnit <= 0x001f) || codeUnit == 0x007f || // If the character is the first character and is in the range [0-9] // (U+0030 to U+0039), […] (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || // If the character is the second character and is in the range [0-9] // (U+0030 to U+0039) and the first character is a `-` (U+002D), […] (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002d) ) { // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point result += '\\' + codeUnit.toString(16) + ' ' continue } // If the character is not handled by one of the above rules and is // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to // U+005A), or [a-z] (U+0061 to U+007A), […] if ( codeUnit >= 0x0080 || codeUnit == 0x002d || codeUnit == 0x005f || (codeUnit >= 0x0030 && codeUnit <= 0x0039) || (codeUnit >= 0x0041 && codeUnit <= 0x005a) || (codeUnit >= 0x0061 && codeUnit <= 0x007a) ) { // the character itself result += string.charAt(index) continue } // Otherwise, the escaped character. // https://drafts.csswg.org/cssom/#escape-a-character result += '\\' + string.charAt(index) } return result } export async function retryAssertion( fn: () => Promise, { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {}, ) { let end = Date.now() + timeout let error: any while (Date.now() < end) { try { return await fn() } catch (err) { error = err await new Promise((resolve) => setTimeout(resolve, delay)) } } throw error } export async function fetchStyles(port: number, path = '/'): Promise { let index = await fetch(`http://localhost:${port}${path}`) let html = await index.text() let linkRegex = /]*>([\s\S]*?)<\/style>/gi let stylesheets: string[] = [] let paths: string[] = [] for (let match of html.matchAll(linkRegex)) { let path: string = match[1] if (path.startsWith('./')) { path = path.slice(1) } paths.push(path) } stylesheets.push( ...(await Promise.all( paths.map(async (path) => { let css = await fetch(`http://localhost:${port}${path}`, { headers: { Accept: 'text/css', }, }) return await css.text() }), )), ) for (let match of html.matchAll(styleRegex)) { stylesheets.push(match[1]) } return stylesheets.reduce((acc, css) => { return acc + '\n' + css }, '') } async function gracefullyRemove(dir: string) { // Skip removing the directory in CI because it can stall on Windows if (!process.env.CI) { await fs.rm(dir, { recursive: true, force: true }) } } async function dirExists(dir: string): Promise { try { return await fs.stat(dir).then((stat) => stat.isDirectory()) } catch { return false } }