mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
This PR improves the integration tests in two ways: 1. Make the integration tests more reliable and thus less flakey 2. Make the integration tests faster (by introducing concurrency) Tried a lot of different things to make sure that these tests are fast and stable. --- The biggest issue we noticed is that some tests are flakey, these are tests with long running dev-mode processes where watchers are being used and/or dev servers are created. To solve this, all the tests that spawn a process look at stdout/stderr and wait for a message from the process to know whether we can start making changes. For example, in case of an Astro project, you get a `watching for file changes` message. In case of Nuxt project you can wait for an `server warmed up in` and in case of Next.js there is a `Ready in` message. These depend on the tools being used, so this is hardcoded per test instead of a magically automatic solution. These messages allow us to wait until all the initial necessary work, internal watchers and/or dev servers are setup before we start making changes to the files and/or request CSS stylesheets before the server(s) are ready. --- Another improvement is how we setup the dev servers. Before, we used to try and get a free port on the system and use a `--port` flag or a `PORT` environment variable. Instead of doing this (which is slow), we rely on the process itself to show a URL with a port. Basically all tools will try to find a free port if the default port is in use. We can then use the stdout/stderr messages to get the URL and the port to use. To reduce the amount of potential conflicts in ports, we used to run every test and every file sequentially to basically guarantee that ports are free. With this new approach where we rely on the process, I noticed that we don't really run into this issue again (I reran the tests multiple times and they were always stable) <img width="316" alt="image" src="https://github.com/user-attachments/assets/b75ddab4-f919-4995-85d0-f212b603e5c2" /> Note: these tests run Linux, Windows and macOS in this branch just for testing purposes. Once this is done, we will only run Linux tests on PRs and run all 3 of them on the `next` branch. We do make the tests concurrent by default now, which in theory means that there could be conflicts (which in practice means that the process has to do a few more tries to find a free port). To reduce these conflicts, we split up the integration tests such that Vite, PostCSS, CLI, … tests all run in a separate job in the GitHub actions workflow. <img width="312" alt="image" src="https://github.com/user-attachments/assets/fe9a58a1-98eb-4d9b-8845-a7c8a7af5766" /> Comparing this branch against the `next` branch, this is what CI looks like right now: | `next` | `feat/improve-integration-tests` | | --- | --- | | <img width="594" alt="image" src="https://github.com/user-attachments/assets/540d21eb-ab03-42e8-9f6f-b3a071fc7635" /> | <img width="672" alt="image" src="https://github.com/user-attachments/assets/8ef2e891-08a1-464b-9954-4153174ebce7" /> | There also was a point in time where I introduced sequential tests such that all spawned processes still run after each other, but so far I didn't run into issues if we keep them concurrent so I dropped that code. Some small changes I made to make things more reliable: 1. When relying on stdout/stderr messages, we split lines on `\n` and we strip all the ANSI escapes which allows us to not worry about special ANSI characters when finding the URL or a specific message to wait for. 2. Once a test is done, we `child.kill()` the spawned process. If that doesn't work, for whatever reason, we run a `child.kill('SIGKILL')` to force kill the process. This could technically lead to some memory or files not being cleaned up properly, but once CI is done, everything is thrown away anyway. 3. As you can see in the screenshots, I used some nicer names for the workflows. | `next` | `feat/improve-integration-tests` | | --- | --- | | <img width="276" alt="image" src="https://github.com/user-attachments/assets/e574bb53-e21b-4619-9cdb-515431b255b9" /> | <img width="179" alt="image" src="https://github.com/user-attachments/assets/8bc75119-fb91-4500-a1d0-bd09f74c93ad" /> | They also look a bit nicer in the PR overview as well: <img width="929" alt="image" src="https://github.com/user-attachments/assets/04fc71fc-74b0-4e7c-9047-2aada664efef" /> The very last commit just filters out Windows and macOS tests again for PRs (but they are executed on the `next` branch. --- ### Nest steps I think for now we are in a pretty good state, but there are some things we can do to further improve everything (mainly make things faster) but aren't necessary. I also ran into issue while trying it so there is more work to do. 1. More splits — instead of having a Vite folder and PostCSS folder, we can go a step further and have folders for Next.js, Astro, Nuxt, Remix, … 2. Caching — right now we have to run the build step for every OS on every "job". We can re-use the work here by introducing a setup job that the other jobs rely on. @thecrypticace and I tried it already, but were running into some Bun specific Standalone CLI issues when doing that. 3. Remote caching — we could re-enable remote caching such that the `build` step can be full turbo (e.g.: after a PR is merged in `next` and we run everything again)
653 lines
21 KiB
TypeScript
653 lines
21 KiB
TypeScript
import dedent from 'dedent'
|
||
import fastGlob from 'fast-glob'
|
||
import { exec, spawn } from 'node:child_process'
|
||
import fs from 'node:fs/promises'
|
||
import { platform, tmpdir } from 'node:os'
|
||
import path from 'node:path'
|
||
import { stripVTControlCharacters } from 'node:util'
|
||
import { test as defaultTest, type ExpectStatic } 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
|
||
env?: Record<string, string>
|
||
}
|
||
|
||
interface ExecOptions {
|
||
ignoreStdErr?: boolean
|
||
}
|
||
|
||
interface TestConfig {
|
||
fs: {
|
||
[filePath: string]: string | Uint8Array
|
||
}
|
||
}
|
||
interface TestContext {
|
||
root: string
|
||
expect: ExpectStatic
|
||
exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
|
||
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
|
||
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 | RegExp | (string | RegExp)[],
|
||
): Promise<void>
|
||
expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
|
||
}
|
||
}
|
||
type TestCallback = (context: TestContext) => Promise<void> | void
|
||
interface TestFlags {
|
||
only?: boolean
|
||
skip?: 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, skip = false, debug = false }: TestFlags = {},
|
||
) {
|
||
return defaultTest(
|
||
name,
|
||
{
|
||
timeout: TEST_TIMEOUT,
|
||
retry: process.env.CI ? 2 : 0,
|
||
only: only || (!process.env.CI && debug),
|
||
skip,
|
||
concurrent: true,
|
||
},
|
||
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,
|
||
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,
|
||
env: childProcessOptions.env,
|
||
},
|
||
(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,
|
||
...childProcessOptions,
|
||
env: {
|
||
...process.env,
|
||
...childProcessOptions.env,
|
||
},
|
||
})
|
||
|
||
function dispose() {
|
||
if (!child.kill()) {
|
||
child.kill('SIGKILL')
|
||
}
|
||
|
||
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])
|
||
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,
|
||
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: string, content: string | Uint8Array): Promise<void> {
|
||
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)
|
||
}
|
||
|
||
// 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, 'utf-8')
|
||
},
|
||
|
||
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')
|
||
|
||
// 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,
|
||
// Drop license comment
|
||
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 = 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/
|
||
`
|
||
|
||
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)
|
||
|
||
// 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"', { 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.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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
if (pkg === 'tailwindcss') {
|
||
// We want to be explicit about the `tailwindcss` package so our tests can
|
||
// also import v3 without conflicting v4 tarballs.
|
||
json.pnpm.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
|
||
json.pnpm.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
|
||
json.pnpm.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
|
||
json.pnpm.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
|
||
json.pnpm.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
|
||
} else {
|
||
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()
|
||
}
|
||
|
||
export let svg = dedent
|
||
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 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())}`
|
||
}
|
||
|
||
// 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(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) => {
|
||
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 })
|
||
}
|
||
}
|