diff --git a/src/features/clean.test.ts b/src/features/clean.test.ts new file mode 100644 index 000000000..badc04aa0 --- /dev/null +++ b/src/features/clean.test.ts @@ -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 = { + 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) + }) +}) diff --git a/src/features/clean.ts b/src/features/clean.ts index 5ab936b03..66e285c30 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -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' @@ -25,16 +25,41 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { } 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) }