mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Alternative to #14110 This PR changes the way how we load plugins to be compatible with ES6 async `import`s. This allows us to load plugins even inside the browser but it comes at a downside: We now have to change the `compile` API to return a `Promise`... So most of this PR is rewriting all of the call sites of `compile` to expect a promise instead of the object. --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me>
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
import dedent from 'dedent'
|
||
import fastGlob from 'fast-glob'
|
||
import killPort from 'kill-port'
|
||
import { execSync, spawn } from 'node:child_process'
|
||
import fs from 'node:fs/promises'
|
||
import net from 'node:net'
|
||
import { homedir, 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 TestConfig {
|
||
fs: {
|
||
[filePath: string]: string
|
||
}
|
||
}
|
||
interface TestContext {
|
||
root: string
|
||
exec(command: string, options?: ChildProcessOptions): 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[]): Promise<void>
|
||
}
|
||
}
|
||
type TestCallback = (context: TestContext) => Promise<void> | void
|
||
interface TestFlags {
|
||
only?: boolean
|
||
debug?: boolean
|
||
}
|
||
|
||
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
|
||
|
||
const TEST_TIMEOUT = 30000
|
||
const ASSERTION_TIMEOUT = 5000
|
||
|
||
export function test(
|
||
name: string,
|
||
config: TestConfig,
|
||
testCallback: TestCallback,
|
||
{ only = false, debug = false }: TestFlags = {},
|
||
) {
|
||
return (only ? defaultTest.only : defaultTest)(
|
||
name,
|
||
{ timeout: TEST_TIMEOUT },
|
||
async (options) => {
|
||
let rootDir = debug
|
||
? path.join(REPO_ROOT, '.debug')
|
||
: // On Windows CI, tmpdir returns a path containing a weird RUNNER~1
|
||
// folder that apparently causes the vite builds to not work.
|
||
process.env.CI && platform() === 'win32'
|
||
? homedir()
|
||
: tmpdir()
|
||
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 = {}) {
|
||
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 execSync(command, {
|
||
cwd,
|
||
stdio: 'pipe',
|
||
...childProcessOptions,
|
||
}).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 immediatly
|
||
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
|
||
}
|
||
|
||
await killPort(port)
|
||
})
|
||
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 (platform() === 'win32') {
|
||
content = content.replace(/\n/g, '\r\n')
|
||
}
|
||
|
||
let dir = path.dirname(full)
|
||
await fs.mkdir(dir, { recursive: true })
|
||
await fs.writeFile(full, content)
|
||
},
|
||
read(filePath: string) {
|
||
return fs.readFile(path.resolve(root, filePath), 'utf8')
|
||
},
|
||
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 retryUntil(async () => {
|
||
let fileContent = await this.read(filePath)
|
||
for (let content of contents) {
|
||
expect(fileContent).toContain(content)
|
||
}
|
||
})
|
||
},
|
||
},
|
||
} satisfies TestContext
|
||
|
||
for (let [filename, content] of Object.entries(config.fs)) {
|
||
await context.fs.write(filename, content)
|
||
}
|
||
|
||
try {
|
||
context.exec('pnpm install')
|
||
} catch (error: any) {
|
||
console.error(error)
|
||
throw error
|
||
}
|
||
|
||
let disposables: (() => Promise<void>)[] = []
|
||
|
||
async function dispose() {
|
||
await Promise.all(disposables.map((dispose) => dispose()))
|
||
|
||
// Skip removing the directory in CI beause it can stall on Windows
|
||
if (!process.env.CI && !debug) {
|
||
await fs.rm(root, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
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, { only: true, 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
|
||
}
|
||
|
||
async function retryUntil<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
|
||
}
|