This PR bumps the repo to pnpm v11 (from v9). I kept running into weird Windows specific issues for the integration tests due to some shims but they all pass right now. Bumping to pnpm v11 also meant that everything is driven by a `pnpm-workspace.yaml` file, and changes applied via the `pnpm` field in the `package.json` don't work anymore. This also moved the `node-linker` setup that was defined in the `.npmrc` file for the wasm oxide build into the `pnpm-workspace.yaml` file. Ideally this is scoped to just this package, but I couldn't get that to work, so it's applied to all packages right now. [ci-all]
870 lines
28 KiB
TypeScript
870 lines
28 KiB
TypeScript
import dedent from 'dedent'
|
|
import fastGlob from 'fast-glob'
|
|
import { exec, execFile, spawn, type ChildProcess } from 'node:child_process'
|
|
import fs from 'node:fs/promises'
|
|
import { createServer } from 'node:net'
|
|
import { platform, tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { promisify, stripVTControlCharacters } from 'node:util'
|
|
import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
|
|
import { test as defaultTest, type ExpectStatic } from 'vitest'
|
|
import * as Yaml from 'yaml'
|
|
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
|
|
import { escape } from '../packages/tailwindcss/src/utils/escape'
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..')
|
|
const ROOT_PNPM_WORKSPACE = Yaml.parse(
|
|
await fs.readFile(path.join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf8'),
|
|
)
|
|
const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
|
|
name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
|
|
)
|
|
|
|
interface SpawnedProcess {
|
|
dispose: () => Promise<void>
|
|
flush: () => void
|
|
onStdout: (predicate: (message: string) => boolean) => Promise<void>
|
|
onStderr: (predicate: (message: string) => boolean) => Promise<void>
|
|
}
|
|
|
|
interface ChildProcessOptions {
|
|
cwd?: string
|
|
env?: Record<string, string>
|
|
}
|
|
|
|
interface ExecOptions {
|
|
ignoreStdErr?: boolean
|
|
stdin?: string
|
|
}
|
|
|
|
interface TestConfig {
|
|
fs: {
|
|
[filePath: string]: string | Uint8Array
|
|
}
|
|
|
|
timeout?: number
|
|
installDependencies?: boolean
|
|
retry?: number
|
|
}
|
|
interface TestContext {
|
|
root: string
|
|
expect: ExpectStatic
|
|
exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
|
|
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
|
|
parseSourceMap(opts: string | SourceMapOptions): SourceMap
|
|
fs: {
|
|
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
|
|
symlink(dst: string, src: string): Promise<void>
|
|
create(filePaths: string[]): Promise<void>
|
|
read(filePath: string): Promise<string>
|
|
delete(filePath: string): Promise<void>
|
|
glob(pattern: string): Promise<[string, string][]>
|
|
dumpFiles(pattern: string): Promise<string>
|
|
expectFileToContain(
|
|
filePath: string,
|
|
contents: string | RegExp | (string | RegExp)[],
|
|
): Promise<void>
|
|
expectFileNotToContain(filePath: string, contents: string[]): Promise<void>
|
|
}
|
|
}
|
|
type TestCallback = (context: TestContext) => Promise<void> | void
|
|
interface TestFlags {
|
|
only?: boolean
|
|
skip?: boolean
|
|
debug?: boolean
|
|
concurrent?: boolean
|
|
}
|
|
|
|
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
|
|
|
|
export const IS_WINDOWS = platform() === 'win32'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
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, skip = false, debug = false, concurrent = false }: TestFlags = {},
|
|
) {
|
|
return defaultTest(
|
|
name,
|
|
{
|
|
timeout: config.timeout ?? TEST_TIMEOUT,
|
|
retry: config.retry ?? (process.env.CI ? 2 : 0),
|
|
only: only || (!process.env.CI && debug),
|
|
skip,
|
|
concurrent,
|
|
},
|
|
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,
|
|
expect: options.expect,
|
|
parseSourceMap,
|
|
async exec(
|
|
command: string,
|
|
childProcessOptions: ChildProcessOptions = {},
|
|
execOptions: ExecOptions = {},
|
|
) {
|
|
let cwd = childProcessOptions.cwd ?? root
|
|
let originalCommand = command
|
|
|
|
// Avoid `pnpm exec upgrade` on Windows because the upgrader runs
|
|
// `pnpm add`, which rewrites Windows `.cmd` shims while the current
|
|
// shim is still executing.
|
|
//
|
|
// Pretty sure this is a pnpm v11 bug on Windows.
|
|
if (IS_WINDOWS && command.includes('pnpm exec upgrade')) {
|
|
for (let base = cwd; ; base = path.dirname(base)) {
|
|
let upgradeBin = path.join(
|
|
base,
|
|
'node_modules',
|
|
'@tailwindcss',
|
|
'upgrade',
|
|
'dist',
|
|
'index.mjs',
|
|
)
|
|
|
|
try {
|
|
await fs.access(upgradeBin)
|
|
command = command.replace('pnpm exec upgrade', `node "${upgradeBin}"`)
|
|
break
|
|
} catch (error: any) {
|
|
if (error?.code !== 'ENOENT') throw error
|
|
}
|
|
|
|
let parent = path.dirname(base)
|
|
if (parent === base) {
|
|
throw new Error(`Unable to resolve @tailwindcss/upgrade from ${cwd}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
let child = exec(
|
|
command,
|
|
{
|
|
cwd,
|
|
...childProcessOptions,
|
|
env: {
|
|
...process.env,
|
|
...childProcessOptions.env,
|
|
},
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
if (command !== originalCommand) {
|
|
error.message = error.message.replace(command, originalCommand)
|
|
let mappedError = error as any
|
|
mappedError.cmd = originalCommand
|
|
}
|
|
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())
|
|
}
|
|
},
|
|
)
|
|
if (execOptions.stdin) {
|
|
child.stdin?.write(execOptions.stdin)
|
|
child.stdin?.end()
|
|
}
|
|
})
|
|
},
|
|
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,
|
|
detached: !IS_WINDOWS,
|
|
shell: true,
|
|
...childProcessOptions,
|
|
env: {
|
|
...process.env,
|
|
...childProcessOptions.env,
|
|
},
|
|
})
|
|
|
|
let disposed = false
|
|
|
|
async function dispose() {
|
|
if (disposed) return disposePromise
|
|
disposed = true
|
|
|
|
await killProcessTree(child)
|
|
|
|
let timer = setTimeout(() => {
|
|
forceKillProcessTree(child)
|
|
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`))
|
|
}, ASSERTION_TIMEOUT)
|
|
disposePromise.then(
|
|
() => clearTimeout(timer),
|
|
() => 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])
|
|
for (let line of content.split('\n')) {
|
|
stdoutMessages.push(stripVTControlCharacters(line))
|
|
}
|
|
notifyNext(stdoutActors, stdoutMessages)
|
|
})
|
|
child.stderr.on('data', (result) => {
|
|
let content = result.toString()
|
|
if (debug || only) console.error(content)
|
|
combined.push(['stderr', content])
|
|
for (let line of content.split('\n')) {
|
|
stderrMessages.push(stripVTControlCharacters(line))
|
|
}
|
|
notifyNext(stderrActors, stderrMessages)
|
|
})
|
|
child.on('exit', onExit)
|
|
child.on('error', (error) => {
|
|
if (error.name !== 'AbortError') {
|
|
throw error
|
|
}
|
|
})
|
|
|
|
options.onTestFailed(() => {
|
|
// In only or debug mode, messages are logged to the console
|
|
// immediately.
|
|
if (only || debug) return
|
|
|
|
for (let [type, message] of combined) {
|
|
if (type === 'stdout') {
|
|
console.log(message)
|
|
} else {
|
|
console.error(message)
|
|
}
|
|
}
|
|
})
|
|
|
|
return {
|
|
dispose,
|
|
flush() {
|
|
stdoutActors.splice(0)
|
|
stderrActors.splice(0)
|
|
|
|
stdoutMessages.splice(0)
|
|
stderrMessages.splice(0)
|
|
},
|
|
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)
|
|
})
|
|
},
|
|
}
|
|
},
|
|
fs: {
|
|
async write(filename, content, encoding = 'utf8') {
|
|
let full = path.join(root, filename)
|
|
let dir = path.dirname(full)
|
|
await fs.mkdir(dir, { recursive: true })
|
|
|
|
if (typeof content !== 'string') {
|
|
return await fs.writeFile(full, content)
|
|
}
|
|
|
|
if (filename.endsWith('package.json')) {
|
|
content = await overwriteVersionsInPackageJson(content)
|
|
} else if (filename.endsWith('pnpm-workspace.yaml')) {
|
|
content = overwriteVersionsInPnpmWorkspace(content)
|
|
}
|
|
|
|
// Ensure that files written on Windows use \r\n line ending
|
|
if (IS_WINDOWS) {
|
|
content = content.replace(/\n/g, '\r\n')
|
|
}
|
|
|
|
await fs.writeFile(full, content, encoding)
|
|
},
|
|
|
|
async symlink(target, src) {
|
|
let targetAbsolute = path.join(root, target)
|
|
let targetParent = path.dirname(targetAbsolute)
|
|
await fs.mkdir(targetParent, { recursive: true })
|
|
|
|
let srcAbsolute = path.join(root, src)
|
|
let srcParent = path.dirname(srcAbsolute)
|
|
await fs.mkdir(srcParent, { recursive: true })
|
|
|
|
await fs.symlink(targetAbsolute, srcAbsolute)
|
|
},
|
|
|
|
async create(filenames) {
|
|
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 delete(filename) {
|
|
await fs.unlink(path.join(root, filename))
|
|
},
|
|
|
|
async read(filePath) {
|
|
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) {
|
|
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,
|
|
// Drop license comment
|
|
content.replace(/[\s\n]*\/\*![\s\S]*?\*\/[\s\n]*/g, ''),
|
|
]
|
|
}),
|
|
)
|
|
},
|
|
async dumpFiles(pattern) {
|
|
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 = zParts.at(-1)
|
|
|
|
// Sort by depth, shallow first
|
|
if (aParts.length < zParts.length) return -1
|
|
if (aParts.length > zParts.length) return 1
|
|
|
|
// Sort by folder names, alphabetically
|
|
for (let i = 0; i < aParts.length - 1; i++) {
|
|
let diff = aParts[i].localeCompare(zParts[i])
|
|
if (diff !== 0) return diff
|
|
}
|
|
|
|
// Sort by filename, sort files named `index` before others
|
|
if (aFile?.startsWith('index') && !zFile?.startsWith('index')) return -1
|
|
if (zFile?.startsWith('index') && !aFile?.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) {
|
|
options.expect(fileContent).toMatch(content)
|
|
} else {
|
|
options.expect(fileContent).toContain(content)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
async expectFileNotToContain(filePath, contents) {
|
|
return retryAssertion(async () => {
|
|
let fileContent = await this.read(filePath)
|
|
for (let content of contents) {
|
|
options.expect(fileContent).not.toContain(content)
|
|
}
|
|
})
|
|
},
|
|
},
|
|
} satisfies TestContext
|
|
|
|
config.fs['.gitignore'] ??= txt`
|
|
node_modules/
|
|
`
|
|
|
|
// Disable Git's automatic line ending conversion in these throwaway test
|
|
// repositories. Tools like pnpm write files (e.g. `pnpm-lock.yaml`) with
|
|
// LF endings, which on Windows (where `core.autocrlf=true` is common)
|
|
// makes Git emit noisy "LF will be replaced by CRLF" warnings when it
|
|
// touches those files.
|
|
config.fs['.gitattributes'] ??= txt`
|
|
* -text
|
|
`
|
|
|
|
// Ensure there is always a root `pnpm-workspace.yaml` so that transitive
|
|
// dependency overrides (and the build allow list) are injected into it.
|
|
// As of pnpm v10, these can no longer live in the `pnpm.overrides` field
|
|
// of `package.json`.
|
|
config.fs['pnpm-workspace.yaml'] ??= ''
|
|
|
|
for (let [filename, content] of Object.entries(config.fs)) {
|
|
if (content.toString().startsWith('symlink:')) {
|
|
// The symlink path is relative to the target destination's path
|
|
let target = path.join(
|
|
filename,
|
|
content.toString().slice('symlink:'.length), // Relative path
|
|
)
|
|
|
|
await context.fs.symlink(target, filename)
|
|
} else {
|
|
await context.fs.write(filename, content)
|
|
}
|
|
}
|
|
|
|
let shouldInstallDependencies = config.installDependencies ?? true
|
|
|
|
try {
|
|
// Every test project gets its own root `pnpm-workspace.yaml` (see
|
|
// above). This makes the test directory its own workspace root, so even
|
|
// in debug mode — where the directory lives inside this repository's
|
|
// pnpm workspace — `pnpm install` won't attach to the parent workspace.
|
|
if (shouldInstallDependencies) {
|
|
await context.exec(`pnpm install`)
|
|
}
|
|
} 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() {
|
|
let results = await Promise.allSettled(disposables.map((dispose) => dispose()))
|
|
|
|
if (!debug) {
|
|
await gracefullyRemove(root)
|
|
}
|
|
|
|
let errors = results.flatMap((result) =>
|
|
result.status === 'rejected' ? [result.reason] : [],
|
|
)
|
|
if (errors.length > 0) {
|
|
throw new AggregateError(errors, 'Failed to clean up spawned processes')
|
|
}
|
|
}
|
|
|
|
options.onTestFinished(dispose)
|
|
|
|
// Make it a git repository, and commit all files
|
|
if (only || debug) {
|
|
try {
|
|
await context.exec('git init', { cwd: root })
|
|
await context.exec('git add --all', { cwd: root })
|
|
await context.exec('git commit -m "before migration" --allow-empty', { cwd: root })
|
|
} catch (error: any) {
|
|
console.error(error)
|
|
console.error(error.stdout?.toString())
|
|
console.error(error.stderr?.toString())
|
|
throw error
|
|
}
|
|
}
|
|
|
|
return await testCallback(context)
|
|
},
|
|
)
|
|
}
|
|
test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
|
|
return test(name, config, testCallback, { only: true })
|
|
}
|
|
test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => {
|
|
return test(name, config, testCallback, { skip: true })
|
|
}
|
|
test.concurrent = (name: string, config: TestConfig, testCallback: TestCallback) => {
|
|
return test(name, config, testCallback, { concurrent: 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
|
|
for (let key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
let dependencies = json[key] || {}
|
|
for (let dependency in dependencies) {
|
|
if (dependencies[dependency] === 'workspace:^') {
|
|
dependencies[dependency] = resolveVersion(dependency)
|
|
}
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(json, null, 2)
|
|
}
|
|
|
|
function overwriteVersionsInPnpmWorkspace(content: string): string {
|
|
let workspace = content.trim() === '' ? {} : Yaml.parse(content)
|
|
|
|
// Inherit information from the root workspace
|
|
workspace.allowBuilds = {
|
|
...ROOT_PNPM_WORKSPACE.allowBuilds,
|
|
...workspace.allowBuilds,
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// As of pnpm v10, the `pnpm.overrides` field in `package.json` is no longer
|
|
// read. Overrides have to be defined in `pnpm-workspace.yaml` instead.
|
|
workspace.overrides ||= {}
|
|
for (let pkg of PUBLIC_PACKAGES) {
|
|
if (pkg === 'tailwindcss') {
|
|
// We want to be explicit about the `tailwindcss` package so our tests can
|
|
// also import v3 without conflicting v4 tarballs.
|
|
workspace.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
|
|
workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
|
|
workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
|
|
workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
|
|
workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
|
|
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
|
|
} else {
|
|
workspace.overrides[pkg] = resolveVersion(pkg)
|
|
}
|
|
}
|
|
|
|
return Yaml.stringify(workspace)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
export let svg = dedent
|
|
export let css = dedent
|
|
export let html = dedent
|
|
export let ts = dedent
|
|
export let js = dedent
|
|
export let jsx = dedent
|
|
export let json = dedent
|
|
export let yaml = dedent
|
|
export let txt = dedent
|
|
|
|
export function binary(str: string | TemplateStringsArray, ...values: unknown[]): Uint8Array {
|
|
let base64 = typeof str === 'string' ? str : String.raw(str, ...values)
|
|
|
|
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
|
|
}
|
|
|
|
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 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.captureStackTrace(err, retryAssertion)
|
|
error = err
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
|
|
export async function fetchStyles(base: string, path = '/'): Promise<string> {
|
|
while (base.endsWith('/')) {
|
|
base = base.slice(0, -1)
|
|
}
|
|
|
|
let index = await fetch(`${base}${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(`${base}${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) => {
|
|
if (acc.length > 0) acc += '\n'
|
|
acc += css
|
|
return acc
|
|
}, '')
|
|
}
|
|
|
|
export async function getRandomPort() {
|
|
return new Promise<number>((resolve, reject) => {
|
|
let server = createServer()
|
|
server.unref()
|
|
server.on('error', reject)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
let address = server.address()
|
|
server.close(() => {
|
|
if (address && typeof address === 'object') {
|
|
resolve(address.port)
|
|
} else {
|
|
reject(new Error('Unable to allocate random port'))
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
async function killProcessTree(child: ChildProcess) {
|
|
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
|
|
return
|
|
}
|
|
|
|
if (IS_WINDOWS) {
|
|
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
timeout: ASSERTION_TIMEOUT,
|
|
windowsHide: true,
|
|
}).catch(() => {})
|
|
return
|
|
}
|
|
|
|
try {
|
|
process.kill(-child.pid, 'SIGTERM')
|
|
} catch (error: any) {
|
|
if (error?.code !== 'ESRCH') {
|
|
child.kill()
|
|
}
|
|
}
|
|
}
|
|
|
|
function forceKillProcessTree(child: ChildProcess) {
|
|
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
|
|
return
|
|
}
|
|
|
|
if (IS_WINDOWS) {
|
|
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {})
|
|
return
|
|
}
|
|
|
|
try {
|
|
process.kill(-child.pid, 'SIGKILL')
|
|
} catch (error: any) {
|
|
if (error?.code !== 'ESRCH') {
|
|
child.kill('SIGKILL')
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }).catch((error) => {
|
|
console.log(`Failed to remove ${dir}`, error)
|
|
})
|
|
}
|
|
}
|
|
|
|
const SOURCE_MAP_COMMENT = /^\/\*# sourceMappingURL=data:application\/json;base64,(.*) \*\/$/
|
|
|
|
export interface SourceMap {
|
|
at(
|
|
line: number,
|
|
column: number,
|
|
): {
|
|
source: string | null
|
|
original: string
|
|
generated: string
|
|
}
|
|
}
|
|
|
|
interface SourceMapOptions {
|
|
/**
|
|
* A raw source map
|
|
*
|
|
* This may be a string or an object. Strings will be decoded.
|
|
*/
|
|
map: string | object
|
|
|
|
/**
|
|
* The content of the generated file the source map is for
|
|
*/
|
|
content: string
|
|
|
|
/**
|
|
* The encoding of the source map
|
|
*
|
|
* Can be used to decode a base64 map (e.g. an inline source map URI)
|
|
*/
|
|
encoding?: BufferEncoding
|
|
}
|
|
|
|
function parseSourceMap(opts: string | SourceMapOptions): SourceMap {
|
|
if (typeof opts === 'string') {
|
|
let lines = opts.trimEnd().split('\n')
|
|
let comment = lines.at(-1) ?? ''
|
|
let map = String(comment).match(SOURCE_MAP_COMMENT)?.[1] ?? null
|
|
if (!map) throw new Error('No source map comment found')
|
|
|
|
return parseSourceMap({
|
|
map,
|
|
content: lines.slice(0, -1).join('\n'),
|
|
encoding: 'base64',
|
|
})
|
|
}
|
|
|
|
let rawMap: RawSourceMap
|
|
let content = opts.content
|
|
|
|
if (typeof opts.map === 'object') {
|
|
rawMap = opts.map as RawSourceMap
|
|
} else {
|
|
rawMap = JSON.parse(Buffer.from(opts.map, opts.encoding ?? 'utf-8').toString())
|
|
}
|
|
|
|
let map = new SourceMapConsumer(rawMap)
|
|
let generatedTable = createLineTable(content)
|
|
|
|
return {
|
|
at(line: number, column: number) {
|
|
let pos = map.originalPositionFor({ line, column })
|
|
let source = pos.source ? map.sourceContentFor(pos.source) : null
|
|
let originalTable = createLineTable(source ?? '')
|
|
let originalOffset = originalTable.findOffset(pos)
|
|
let generatedOffset = generatedTable.findOffset({ line, column })
|
|
|
|
return {
|
|
source: pos.source,
|
|
original: source
|
|
? source.slice(originalOffset, originalOffset + 10).trim() + '...'
|
|
: '(none)',
|
|
generated: content.slice(generatedOffset, generatedOffset + 10).trim() + '...',
|
|
}
|
|
},
|
|
}
|
|
}
|