mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
- Avoid cross-device copying in Windows CI by setting the tests dir to the same drive as the workspace. - Disable LTO and use a faster linker for the Rust build Buid: ~3min -> ~2min Integration Tests: ~8min -> ~3min20s
604 lines
19 KiB
TypeScript
604 lines
19 KiB
TypeScript
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>
|
||
read(filePath: string): Promise<string>
|
||
glob(pattern: string): Promise<[string, 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
|
||
|
||
// 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<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) 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<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 () => {
|
||
// 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<void> {
|
||
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<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 })
|
||
}
|
||
|
||
// 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<string> {
|
||
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<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())}`
|
||
}
|
||
|
||
// 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<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) {
|
||
// 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<boolean> {
|
||
try {
|
||
return await fs.stat(dir).then((stat) => stat.isDirectory())
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|