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<void>
onStderr: (predicate: (message: string) => boolean) => Promise<void>
}
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<string>
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
getFreePort(): Promise<number>
fs: {
write(filePath: string, content: string): Promise<void>
create(filePaths: string[]): Promise<void>
read(filePath: string): Promise<string>
glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise<string>
expectFileToContain(
filePath: string,
contents: string | string[] | RegExp | RegExp[],
): Promise<void>
expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
}
}
type TestCallback = (context: TestContext) => Promise<void> | 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
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, retry: process.env.CI ? 2 : 0 },
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)
if (only || debug) {
console.error(stdout)
}
reject(error)
} else {
if (only || debug) {
console.log(stdout.toString() + '\n\n' + stderr.toString())
}
resolve(stdout.toString() + '\n\n' + stderr.toString())
}
},
)
})
},
async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) {
let resolveDisposal: (() => void) | undefined
let rejectDisposal: ((error: Error) => void) | undefined
let disposePromise = new Promise<void>((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 || only) console.log(content)
combined.push(['stdout', content])
stdoutMessages.push(content)
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)
notifyNext(stderrActors, stderrMessages)
})
child.on('exit', onExit)
child.on('error', (error) => {
if (error.name !== 'AbortError') {
throw error
}
})
options.onTestFailed(() => {
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<void>((resolve) => {
stdoutActors.push({ predicate, resolve })
notifyNext(stdoutActors, stdoutMessages)
})
},
onStderr(predicate: (message: string) => boolean) {
return new Promise<void>((resolve) => {
stderrActors.push({ predicate, resolve })
notifyNext(stderrActors, stderrMessages)
})
},
}
},
async getFreePort(): Promise<number> {
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 () => {
await new Promise((resolve) => setTimeout(resolve, 10))
let isPortTaken = await testIfPortTaken(port)
if (!isPortTaken) {
return
}
try {
await killPort(port)
} catch {
}
})
resolve(port)
}
})
})
})
},
fs: {
async write(filename: string, content: string): Promise<void> {
let full = path.join(root, filename)
if (filename.endsWith('package.json')) {
content = await overwriteVersionsInPackageJson(content)
}
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 create(filenames: string[]): Promise<void> {
for (let filename of filenames) {
let full = path.join(root, filename)
let dir = path.dirname(full)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(full, '')
}
},
async read(filePath: string) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
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.replace(/[\s\n]*\/\*! tailwindcss .*? \*\/[\s\n]*/g, ''),
]
}),
)
},
async dumpFiles(pattern: string) {
let files = await context.fs.glob(pattern)
return `\n${files
.slice()
.sort((a: [string], z: [string]) => {
let aParts = a[0].split('/')
let zParts = z[0].split('/')
let aFile = aParts.at(-1)
let zFile = aParts.at(-1)
// Sort by depth, shallow first
if (aParts.length < zParts.length) return -1
if (aParts.length > zParts.length) return 1
// Sort by filename, sort files named `index` before others
if (aFile?.startsWith('index')) return -1
if (zFile?.startsWith('index')) return 1
// Sort by filename, alphabetically
return a[0].localeCompare(z[0])
})
.map(([file, content]) => `--- ${file} ---\n${content || '<EMPTY>'}`)
.join('\n\n')
.trim()}\n`
},
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 {
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<void>)[] = []
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 })
}
function pkgToFilename(name: string) {
return `${name.replace('@', '').replace('/', '-')}.tgz`
}
async function overwriteVersionsInPackageJson(content: string): Promise<string> {
let json = JSON.parse(content)
;['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach(
(key) => {
let dependencies = json[key] || {}
for (let dependency in dependencies) {
if (dependencies[dependency] === 'workspace:^') {
dependencies[dependency] = resolveVersion(dependency)
}
}
},
)
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<boolean> {
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())}`
}
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 (
length == 1 &&
firstCodeUnit == 0x002d
) {
return '\\' + string
}
while (++index < length) {
codeUnit = string.charCodeAt(index)
if (codeUnit == 0x0000) {
result += '\uFFFD'
continue
}
if (
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
codeUnit == 0x007f ||
(index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002d)
) {
result += '\\' + codeUnit.toString(16) + ' '
continue
}
if (
codeUnit >= 0x0080 ||
codeUnit == 0x002d ||
codeUnit == 0x005f ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
) {
result += string.charAt(index)
continue
}
result += '\\' + string.charAt(index)
}
return result
}
export async function retryAssertion<T>(
fn: () => Promise<T>,
{ 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<string> {
let index = await fetch(`http://localhost:${port}${path}`)
let html = await index.text()
let linkRegex = /<link rel="stylesheet" href="([a-zA-Z0-9\/_\.\?=%-]+)"/gi
let styleRegex = /<style\b[^>]*>([\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) {
if (!process.env.CI) {
await fs.rm(dir, { recursive: true, force: true })
}
}