Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/features/clean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'node:fs'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { fsExists, fsRemove } from '../utils/fs.ts'
import { cleanOutDir, resolveClean } from './clean.ts'
import type { ResolvedConfig, UserConfig } from '../config/index.ts'

let cwd: string

beforeEach(async () => {
cwd = await mkdtemp(path.join(tmpdir(), 'tsdown-clean-'))
})

afterEach(async () => {
vi.restoreAllMocks()
syncBuiltinESMExports()
await fsRemove(cwd)
})

async function writeFiles(files: string[]) {
for (const file of files) {
const filePath = path.join(cwd, file)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, '')
}
}

async function clean(outDir: string, clean: UserConfig['clean']) {
const resolvedOutDir = path.resolve(cwd, outDir)
const config: Partial<ResolvedConfig> = {
cwd,
outDir: resolvedOutDir,
clean: resolveClean(clean, resolvedOutDir, cwd),
}
// Patch the CommonJS module and sync the ESM bindings so the spy sees the
// directory reads made inside tinyglobby
const readdir = vi.spyOn(fs, 'readdir')
syncBuiltinESMExports()
await cleanOutDir([config as ResolvedConfig])
return readdir.mock.calls.map(([dir]) =>
path.relative(cwd, String(dir)).replaceAll('\\', '/'),
)
}

describe('cleanOutDir', () => {
test('lists only the default output directory', async () => {
await writeFiles(['dist/old.js', 'dist/nested/old.js', 'sibling.txt'])

const listed = await clean('dist', true)

expect(listed.toSorted()).toEqual(['dist', 'dist/nested'])
expect(await fsExists(path.join(cwd, 'dist'))).toBe(true)
expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false)
expect(await fsExists(path.join(cwd, 'dist/nested'))).toBe(false)
expect(await fsExists(path.join(cwd, 'sibling.txt'))).toBe(true)
})

test('lists only a nested custom output directory', async () => {
await writeFiles(['out/lib/old.js', 'out/other.js'])

const listed = await clean('out/lib', true)

expect(listed).toEqual(['out/lib'])
expect(await fsExists(path.join(cwd, 'out/lib/old.js'))).toBe(false)
expect(await fsExists(path.join(cwd, 'out/other.js'))).toBe(true)
})

test('does not list anything outside a missing output directory', async () => {
const listed = await clean('dist', true)

expect(listed).toEqual(['dist'])
})

test('still removes other custom patterns', async () => {
await writeFiles(['dist/old.js', 'temp/old.js', 'app.log', 'keep.js'])

await clean('dist', ['dist', 'temp', '*.log'])

expect(await fsExists(path.join(cwd, 'dist'))).toBe(true)
expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false)
expect(await fsExists(path.join(cwd, 'temp'))).toBe(false)
expect(await fsExists(path.join(cwd, 'app.log'))).toBe(false)
expect(await fsExists(path.join(cwd, 'keep.js'))).toBe(true)
})

test('respects negated patterns', async () => {
await writeFiles(['dist/old.js', 'dist/keep.js'])

await clean('dist', ['dist', '!dist/keep.js'])

expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false)
expect(await fsExists(path.join(cwd, 'dist/keep.js'))).toBe(true)
})
})
43 changes: 34 additions & 9 deletions src/features/clean.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path'
import { createDebug } from 'obug'
import { glob } from 'tinyglobby'
import { glob, isDynamicPattern } from 'tinyglobby'
import { fsRemove } from '../utils/fs.ts'
import { slash } from '../utils/general.ts'
import { globalLogger } from '../utils/logger.ts'
Expand All @@ -25,16 +25,41 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise<void> {
}

if (!config.clean.length) continue
const files = await glob(config.clean, {
cwd: config.cwd,
absolute: true,
onlyFiles: false,
dot: true,
})

const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, '')
// Matching a literal `outDir` pattern from `cwd` makes the glob list `cwd`
// itself, so list only the contents of `outDir` instead. Negated patterns
// can exclude files inside `outDir`, so keep the combined glob for them.
const outDir = path.resolve(config.outDir)
const scopeToOutDir = config.clean.every(
(pattern) => !pattern.startsWith('!'),
)
let cleansOutDir = false
const patterns: string[] = []
for (const pattern of config.clean) {
if (
scopeToOutDir &&
!isDynamicPattern(pattern) &&
path.resolve(config.cwd, pattern) === outDir
) {
cleansOutDir = true
} else {
patterns.push(pattern)
}
}

const globOptions = { absolute: true, onlyFiles: false, dot: true }
const files: string[] = []
if (cleansOutDir) {
files.push(...(await glob('**', { ...globOptions, cwd: outDir })))
}
if (patterns.length) {
files.push(...(await glob(patterns, { ...globOptions, cwd: config.cwd })))
}

// Glob results use forward slashes, so compare them in the same form
const normalizedOutDir = slash(config.outDir).replace(RE_LAST_SLASH, '')
for (const file of files) {
const normalizedFile = file.replace(RE_LAST_SLASH, '')
const normalizedFile = slash(file).replace(RE_LAST_SLASH, '')
if (normalizedFile !== normalizedOutDir) {
removes.add(file)
}
Expand Down
Loading