Repository: rolldown/tsdown
Pull request #208: test: add test case for nested dependencies dts bundling
Author: login: ocavue
Created: 2025-05-07T01:18:23Z | Merged: 2025-05-07T04:06:25Z

<!-- DO NOT IGNORE THE TEMPLATE!

Thank you for contributing!

Before submitting the PR, please make sure you do the following:

- Read the [Contributing Guide](https://github.com/sxzz/contribute).
- Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
- Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).
- Ideally, include relevant tests that fail without this PR but pass with it.

-->

### Description

<!-- Please insert your description here and provide especially info about the "what" this PR is solving -->

This PR adds test for https://github.com/rolldown/tsdown/issues/206 

More specifically, `tests/__snapshots__/-206.snap.md` should only contains `index.d.ts` and `index.js`, but it has an extra `css-lIXBXmkh.js` file. 

### Linked Issues

### Additional context

<!-- e.g. is there anything you'd like reviewers to focus on? -->



Diff:
diff --git a/tests/__snapshots__/-206.snap.md b/tests/__snapshots__/-206.snap.md
new file mode 100644
index 000000000..3aceeb229
--- /dev/null
+++ b/tests/__snapshots__/-206.snap.md
@@ -0,0 +1,11 @@
+## index.d.ts
+
+```ts
+export * from "pkg1";
+```
+## index.js
+
+```js
+export * from "pkg1"
+
+```
\ No newline at end of file
diff --git a/tests/fixtures/issue-206/.gitignore b/tests/fixtures/issue-206/.gitignore
new file mode 100644
index 000000000..bd5535a60
--- /dev/null
+++ b/tests/fixtures/issue-206/.gitignore
@@ -0,0 +1 @@
+pnpm-lock.yaml
diff --git a/tests/fixtures/issue-206/package.json b/tests/fixtures/issue-206/package.json
new file mode 100644
index 000000000..92ce5b465
--- /dev/null
+++ b/tests/fixtures/issue-206/package.json
@@ -0,0 +1,5 @@
+{
+  "name": "issue-206",
+  "version": "0.0.0",
+  "private": true
+}
diff --git a/tests/fixtures/issue-206/packages/my-langs/css.mjs b/tests/fixtures/issue-206/packages/my-langs/css.mjs
new file mode 100644
index 000000000..99c878893
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-langs/css.mjs
@@ -0,0 +1 @@
+export default ['CSS']
diff --git a/tests/fixtures/issue-206/packages/my-langs/html.mjs b/tests/fixtures/issue-206/packages/my-langs/html.mjs
new file mode 100644
index 000000000..ba94c63d1
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-langs/html.mjs
@@ -0,0 +1,2 @@
+import css from './css.mjs'
+export default [...css, 'HTML']
diff --git a/tests/fixtures/issue-206/packages/my-langs/package.json b/tests/fixtures/issue-206/packages/my-langs/package.json
new file mode 100644
index 000000000..afad0a1c3
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-langs/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "my-langs",
+  "version": "1.0.0",
+  "type": "module",
+  "exports": {
+    "./css": "./css.mjs",
+    "./html": "./html.mjs"
+  },
+  "sideEffects": false
+}
diff --git a/tests/fixtures/issue-206/packages/my-shiki/index.d.mts b/tests/fixtures/issue-206/packages/my-shiki/index.d.mts
new file mode 100644
index 000000000..8c068e84f
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-shiki/index.d.mts
@@ -0,0 +1 @@
+export declare const codeToHtml: (code: string) => string
diff --git a/tests/fixtures/issue-206/packages/my-shiki/index.mjs b/tests/fixtures/issue-206/packages/my-shiki/index.mjs
new file mode 100644
index 000000000..c8fb6e7d4
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-shiki/index.mjs
@@ -0,0 +1,5 @@
+const langs = [() => import('my-langs/css'), () => import('my-langs/html')]
+
+export const codeToHtml = async (code) => {
+  return String(langs) + code
+}
diff --git a/tests/fixtures/issue-206/packages/my-shiki/package.json b/tests/fixtures/issue-206/packages/my-shiki/package.json
new file mode 100644
index 000000000..95a087c18
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/my-shiki/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "my-shiki",
+  "version": "1.0.0",
+  "type": "module",
+  "main": "./index.mjs",
+  "module": "./index.mjs",
+  "types": "./index.d.mts",
+  "sideEffects": false,
+  "dependencies": {
+    "my-langs": "workspace:*"
+  }
+}
diff --git a/tests/fixtures/issue-206/packages/pkg1/package.json b/tests/fixtures/issue-206/packages/pkg1/package.json
new file mode 100644
index 000000000..eb165854f
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg1/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "pkg1",
+  "version": "1.0.0",
+  "type": "module",
+  "main": "./src/index.ts",
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "dependencies": {
+    "my-shiki": "workspace:*"
+  }
+}
diff --git a/tests/fixtures/issue-206/packages/pkg1/src/index.ts b/tests/fixtures/issue-206/packages/pkg1/src/index.ts
new file mode 100644
index 000000000..aeca65597
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg1/src/index.ts
@@ -0,0 +1,5 @@
+import { codeToHtml } from 'my-shiki'
+
+export const func1 = async (code: string): Promise<string> => {
+  return codeToHtml(code)
+}
diff --git a/tests/fixtures/issue-206/packages/pkg1/tsconfig.json b/tests/fixtures/issue-206/packages/pkg1/tsconfig.json
new file mode 100644
index 000000000..add9c94f8
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg1/tsconfig.json
@@ -0,0 +1,24 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "composite": true,
+    "target": "ES2020",
+    "moduleDetection": "force",
+    "module": "preserve",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "allowJs": true,
+    "strict": true,
+    "noFallthroughCasesInSwitch": true,
+    "noImplicitOverride": true,
+    "noUnusedLocals": true,
+    "emitDeclarationOnly": true,
+    "outDir": "${configDir}/node_modules/.cache/tsconfig/out",
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "isolatedModules": true,
+    "verbatimModuleSyntax": true,
+    "skipLibCheck": true
+  },
+  "include": ["${configDir}/**/*"]
+}
diff --git a/tests/fixtures/issue-206/packages/pkg2/package.json b/tests/fixtures/issue-206/packages/pkg2/package.json
new file mode 100644
index 000000000..ec119bcb4
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg2/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "pkg2",
+  "version": "1.0.0",
+  "type": "module",
+  "main": "./src/index.ts",
+  "dependencies": {
+    "pkg1": "workspace:*"
+  },
+  "devDependencies": {
+    "typescript": "catalog:"
+  }
+}
diff --git a/tests/fixtures/issue-206/packages/pkg2/src/index.ts b/tests/fixtures/issue-206/packages/pkg2/src/index.ts
new file mode 100644
index 000000000..41b3ff804
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg2/src/index.ts
@@ -0,0 +1 @@
+export * from 'pkg1'
diff --git a/tests/fixtures/issue-206/packages/pkg2/tsconfig.json b/tests/fixtures/issue-206/packages/pkg2/tsconfig.json
new file mode 100644
index 000000000..c926570eb
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg2/tsconfig.json
@@ -0,0 +1,29 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "composite": true,
+    "target": "ES2020",
+    "moduleDetection": "force",
+    "module": "preserve",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "allowJs": true,
+    "strict": true,
+    "noFallthroughCasesInSwitch": true,
+    "noImplicitOverride": true,
+    "noUnusedLocals": true,
+    "emitDeclarationOnly": true,
+    "outDir": "${configDir}/node_modules/.cache/tsconfig/out",
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "isolatedModules": true,
+    "verbatimModuleSyntax": true,
+    "skipLibCheck": true
+  },
+  "references": [
+    {
+      "path": "../pkg1/tsconfig.json"
+    }
+  ],
+  "include": ["${configDir}/**/*"]
+}
diff --git a/tests/fixtures/issue-206/packages/pkg2/tsdown.config.mjs b/tests/fixtures/issue-206/packages/pkg2/tsdown.config.mjs
new file mode 100644
index 000000000..7c88a4ade
--- /dev/null
+++ b/tests/fixtures/issue-206/packages/pkg2/tsdown.config.mjs
@@ -0,0 +1,5 @@
+export default {
+  entry: './src/index.ts',
+  outDir: './dist',
+  dts: true,
+}
diff --git a/tests/fixtures/issue-206/pnpm-workspace.yaml b/tests/fixtures/issue-206/pnpm-workspace.yaml
new file mode 100644
index 000000000..7332d0d51
--- /dev/null
+++ b/tests/fixtures/issue-206/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+  - 'packages/*'
+catalog:
+  typescript: ^5.8.3
diff --git a/tests/index.test.ts b/tests/index.test.ts
index 22dd90176..927d6a8cf 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -10,8 +10,11 @@ beforeEach(async (context) => {
 
 test('basic', async (context) => {
   const content = `console.log("Hello, world!")`
-  const { snapshot } = await testBuild(context, {
-    'index.ts': content,
+  const { snapshot } = await testBuild({
+    context,
+    files: {
+      'index.ts': content,
+    },
   })
   expect(snapshot).contain(content)
 })
@@ -22,38 +25,42 @@ test('basic', async (context) => {
     'foo.ts': 'export const foo = 1',
   }
   test('esm import', async (context) => {
-    await testBuild(context, files)
+    await testBuild({ context, files })
   })
 
   test('cjs import', async (context) => {
-    await testBuild(context, files, {
-      format: 'cjs',
+    await testBuild({
+      context,
+      files,
+      options: {
+        format: 'cjs',
+      },
     })
   })
 }
 
 test('esm shims', async (context) => {
-  await testBuild(
+  await testBuild({
     context,
-    { 'index.ts': 'export default [__dirname, __filename]' },
-    { shims: true },
-  )
+    files: { 'index.ts': 'export default [__dirname, __filename]' },
+    options: { shims: true },
+  })
 })
 
 test('cjs shims', async (context) => {
-  const { snapshot } = await testBuild(
+  const { snapshot } = await testBuild({
     context,
-    {
+    files: {
       'index.ts': `
         export const url = import.meta.url
         export const filename = import.meta.filename
         export const dirname = import.meta.dirname`,
     },
-    {
+    options: {
       shims: true,
       format: 'cjs',
     },
-  )
+  })
   expect(snapshot).contain('require("url").pathToFileURL(__filename).href')
   expect(snapshot).contain('__filename')
   expect(snapshot).contain('__dirname')
@@ -64,8 +71,12 @@ test('entry structure', async (context) => {
     'src/index.ts': '',
     'src/utils/index.ts': '',
   }
-  await testBuild(context, files, {
-    entry: Object.keys(files),
+  await testBuild({
+    context,
+    files,
+    options: {
+      entry: Object.keys(files),
+    },
   })
 })
 
@@ -78,9 +89,13 @@ test('bundle dts', async (context) => {
     'src/utils/types.ts': 'export let str = "hello"',
     'src/utils/shared.ts': 'export let shared = 10',
   }
-  await testBuild(context, files, {
-    entry: ['src/index.ts'],
-    dts: true,
+  await testBuild({
+    context,
+    files,
+    options: {
+      entry: ['src/index.ts'],
+      dts: true,
+    },
   })
 })
 
@@ -91,8 +106,12 @@ test('cjs interop', async (context) => {
     export type Foo = string
     `,
   }
-  await testBuild(context, files, {
-    format: ['esm', 'cjs'],
+  await testBuild({
+    context,
+    files,
+    options: {
+      format: ['esm', 'cjs'],
+    },
   })
 })
 
@@ -100,10 +119,14 @@ test('fixed extension', async (context) => {
   const files = {
     'index.ts': `export default 10`,
   }
-  await testBuild(context, files, {
-    format: ['esm', 'cjs'],
-    fixedExtension: true,
-    dts: true,
+  await testBuild({
+    context,
+    files,
+    options: {
+      format: ['esm', 'cjs'],
+      fixedExtension: true,
+      dts: true,
+    },
   })
 })
 
@@ -111,9 +134,13 @@ test('custom extension', async (context) => {
   const files = {
     'index.ts': `export default 10`,
   }
-  const { outputFiles } = await testBuild(context, files, {
-    dts: true,
-    outExtensions: () => ({ js: '.some.mjs', dts: '.some.d.mts' }),
+  const { outputFiles } = await testBuild({
+    context,
+    files,
+    options: {
+      dts: true,
+      outExtensions: () => ({ js: '.some.mjs', dts: '.some.d.mts' }),
+    },
   })
   expect(outputFiles).toMatchInlineSnapshot(`
     [
@@ -127,18 +154,22 @@ test('noExternal', async (context) => {
   const files = {
     'index.ts': `export * from 'cac'`,
   }
-  await testBuild(context, files, {
-    noExternal: ['cac'],
-    plugins: [
-      {
-        name: 'remove-code',
-        load(id) {
-          if (id.replaceAll('\\', '/').includes('/node_modules/cac')) {
-            return 'export const cac = "[CAC CODE]"'
-          }
+  await testBuild({
+    context,
+    files,
+    options: {
+      noExternal: ['cac'],
+      plugins: [
+        {
+          name: 'remove-code',
+          load(id) {
+            if (id.replaceAll('\\', '/').includes('/node_modules/cac')) {
+              return 'export const cac = "[CAC CODE]"'
+            }
+          },
         },
-      },
-    ],
+      ],
+    },
   })
 })
 
@@ -185,8 +216,12 @@ test('resolve dependency for dts', async (context) => {
     'index.ts': `export type { GlobOptions } from 'tinyglobby'
     export type * from 'unconfig'`,
   }
-  const { snapshot } = await testBuild(context, files, {
-    dts: { resolve: ['tinyglobby'] },
+  const { snapshot } = await testBuild({
+    context,
+    files,
+    options: {
+      dts: { resolve: ['tinyglobby'] },
+    },
   })
   expect(snapshot).contain(`export * from "unconfig"`)
 })
@@ -201,9 +236,13 @@ test('resolve paths in tsconfig', async (context) => {
       },
     }),
   }
-  await testBuild(context, files, {
-    dts: { isolatedDeclarations: true },
-    tsconfig: 'tsconfig.build.json',
+  await testBuild({
+    context,
+    files,
+    options: {
+      dts: { isolatedDeclarations: true },
+      tsconfig: 'tsconfig.build.json',
+    },
   })
 })
 
@@ -212,11 +251,15 @@ test('hooks', async (context) => {
   const files = {
     'index.ts': `export default 10`,
   }
-  await testBuild(context, files, {
-    hooks: {
-      'build:prepare': fn,
-      'build:before': fn,
-      'build:done': fn,
+  await testBuild({
+    context,
+    files,
+    options: {
+      hooks: {
+        'build:prepare': fn,
+        'build:before': fn,
+        'build:done': fn,
+      },
     },
   })
   expect(fn).toBeCalledTimes(3)
@@ -230,11 +273,15 @@ test('env flag', async (context) => {
     export const debug = import.meta.env.DEBUG
     `,
   }
-  const { snapshot } = await testBuild(context, files, {
-    env: {
-      NODE_ENV: 'production',
-      CUSTOM: 'tsdown',
-      DEBUG: true,
+  const { snapshot } = await testBuild({
+    context,
+    files,
+    options: {
+      env: {
+        NODE_ENV: 'production',
+        CUSTOM: 'tsdown',
+        DEBUG: true,
+      },
     },
   })
   expect(snapshot).contains('const env = "production"')
@@ -245,11 +292,15 @@ test('env flag', async (context) => {
 
 test('minify', async (context) => {
   const files = { 'index.ts': `export const foo = true` }
-  const { snapshot } = await testBuild(context, files, {
-    minify: {
-      mangle: true,
-      compress: true,
-      removeWhitespace: false,
+  const { snapshot } = await testBuild({
+    context,
+    files,
+    options: {
+      minify: {
+        mangle: true,
+        compress: true,
+        removeWhitespace: false,
+      },
     },
   })
   expect(snapshot).contains('!0')
@@ -259,9 +310,13 @@ test('minify', async (context) => {
 
 test('iife and umd', async (context) => {
   const files = { 'index.ts': `export const foo = true` }
-  const { outputFiles } = await testBuild(context, files, {
-    format: ['iife', 'umd'],
-    globalName: 'Lib',
+  const { outputFiles } = await testBuild({
+    context,
+    files,
+    options: {
+      format: ['iife', 'umd'],
+      globalName: 'Lib',
+    },
   })
   expect(outputFiles).toMatchInlineSnapshot(`
     [
@@ -281,8 +336,12 @@ test('remove node protocol', async (context) => {
     export { fs, join, promise }
     `,
   }
-  const { snapshot } = await testBuild(context, files, {
-    removeNodeProtocol: true,
+  const { snapshot } = await testBuild({
+    context,
+    files,
+    options: {
+      removeNodeProtocol: true,
+    },
   })
   expect(snapshot).not.contains('node:')
 })
diff --git a/tests/issues.test.ts b/tests/issues.test.ts
index 7c0dda60d..41d397208 100644
--- a/tests/issues.test.ts
+++ b/tests/issues.test.ts
@@ -1,4 +1,5 @@
-import { beforeEach, test } from 'vitest'
+import { exec } from 'tinyexec'
+import { beforeEach, expect, test } from 'vitest'
 import { fsRemove } from '../src/utils/fs'
 import { getTestDir, testBuild } from './utils'
 
@@ -8,9 +9,9 @@ beforeEach(async (context) => {
 })
 
 test('#61', async (context) => {
-  await testBuild(
+  await testBuild({
     context,
-    {
+    files: {
       'index.ts': `
       export * as debug from "debug"
       export * as foo from "~/foo"
@@ -23,11 +24,32 @@ test('#61', async (context) => {
         },
       }),
     },
-    {
+    options: {
       external: ['hono/compress', 'hono', 'hono/pretty-json'],
       skipNodeModulesBundle: true,
       target: 'es2022',
       platform: 'node',
     },
-  )
+  })
+})
+
+test('#206', async (context) => {
+  const { outputFiles } = await testBuild({
+    context,
+    fixture: 'issue-206',
+    cwd: 'packages/pkg2',
+    options: {
+      entry: 'src/index.ts',
+      outDir: 'dist',
+      dts: true,
+    },
+    beforeBuild: async () => {
+      await exec('pnpm', ['install'], {
+        nodeOptions: {
+          stdio: ['ignore', 'ignore', 'inherit'],
+        },
+      })
+    },
+  })
+  expect(outputFiles.sort()).toEqual(['index.d.ts', 'index.js'])
 })
diff --git a/tests/target.test.ts b/tests/target.test.ts
index 2120c2454..d82ab4be8 100644
--- a/tests/target.test.ts
+++ b/tests/target.test.ts
@@ -2,37 +2,37 @@ import { expect, test } from 'vitest'
 import { testBuild } from './utils'
 
 test('js syntax lowering', async (context) => {
-  const { snapshot } = await testBuild(
+  const { snapshot } = await testBuild({
     context,
-    { 'index.ts': 'export const foo: number = a?.b?.()' },
-    { target: 'es2015' },
-  )
+    files: { 'index.ts': 'export const foo: number = a?.b?.()' },
+    options: { target: 'es2015' },
+  })
   expect(snapshot).not.contain('?.')
 })
 
 test('unnecessary js syntax lowering', async (context) => {
-  const { snapshot } = await testBuild(
+  const { snapshot } = await testBuild({
     context,
-    { 'index.ts': 'export const foo: number = a?.b?.()' },
-    { target: ['chrome120', 'safari16', 'firefox120'] },
-  )
+    files: { 'index.ts': 'export const foo: number = a?.b?.()' },
+    options: { target: ['chrome120', 'safari16', 'firefox120'] },
+  })
   expect(snapshot).contain('?.')
 })
 
 test('css syntax lowering', async (context) => {
-  const { snapshot } = await testBuild(
+  const { snapshot } = await testBuild({
     context,
-    { 'index.css': '.foo { & .bar { color: red } }' },
-    { entry: 'index.css', target: 'chrome108' },
-  )
+    files: { 'index.css': '.foo { & .bar { color: red } }' },
+    options: { entry: 'index.css', target: 'chrome108' },
+  })
   expect(snapshot).not.contain('&')
 })
 
 test('unnecessary css syntax lowering', async (context) => {
-  const { snapshot } = await testBuild(
+  const { snapshot } = await testBuild({
     context,
-    { 'index.css': '.foo { & .bar { color: red } }' },
-    { entry: 'index.css', target: ['safari18.4'] },
-  )
+    files: { 'index.css': '.foo { & .bar { color: red } }' },
+    options: { entry: 'index.css', target: ['safari18.4'] },
+  })
   expect(snapshot).contain('&')
 })
diff --git a/tests/utils.ts b/tests/utils.ts
index 587bbf7a8..3492179a6 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -1,14 +1,16 @@
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import path from 'node:path'
 import process from 'node:process'
 import { fileURLToPath } from 'node:url'
 import { expectFilesSnapshot } from '@sxzz/test-utils'
+import { glob } from 'tinyglobby'
 import { build, type Options } from '../src/index'
 import type { RunnerTask, TestContext } from 'vitest'
 
 const dirname = path.dirname(fileURLToPath(import.meta.url))
 const tmpDir = path.resolve(dirname, 'temp')
 const snapshotsDir = path.resolve(dirname, '__snapshots__')
+const fixturesDir = path.resolve(dirname, 'fixtures')
 
 function getTestFilename(task: RunnerTask) {
   return filenamify(task.name)
@@ -23,12 +25,38 @@ export function getTestDir(testName: RunnerTask | string): string {
 
 export async function writeFixtures(
   { task }: TestContext,
-  files: Record<string, string>,
+  files?: Record<string, string>,
+  fixtureName?: string,
 ): Promise<{ testName: string; testDir: string }> {
   const testName = getTestFilename(task)
   const testDir = getTestDir(testName)
   await mkdir(testDir, { recursive: true })
 
+  if (files && fixtureName) {
+    throw new Error('Cannot provide both `files` and `fixtureName`')
+  }
+
+  if (fixtureName) {
+    const fixtureDir = path.resolve(fixturesDir, fixtureName)
+    const filePaths = await glob('**/*', {
+      cwd: fixtureDir,
+      followSymbolicLinks: false,
+    })
+    files = Object.fromEntries(
+      await Promise.all(
+        filePaths.map(async (filePath) => {
+          const absoluteFilePath = path.resolve(fixtureDir, filePath)
+          const content = await readFile(absoluteFilePath, 'utf8')
+          return [filePath, content]
+        }),
+      ),
+    )
+  }
+
+  if (!files) {
+    throw new Error('Either `files` or `fixtureName` must be provided')
+  }
+
   for (const [filename, content] of Object.entries(files)) {
     const filepath = path.resolve(testDir, filename)
     await mkdir(path.dirname(filepath), { recursive: true })
@@ -38,20 +66,60 @@ export async function writeFixtures(
   return { testName, testDir }
 }
 
-export async function testBuild(
-  context: TestContext,
-  files: Record<string, string>,
-  options?: Options,
-): Promise<{
+export interface TestBuildOptions {
+  context: TestContext
+
+  /**
+   * The files to write to the test directory.
+   *
+   * One of `files` or `fixtureName` must be provided.
+   */
+  files?: Record<string, string>
+
+  /**
+   * The directory name under `tests/fixtures` to use as a fixture.
+   *
+   * One of `files` or `fixtureName` must be provided.
+   */
+  fixture?: string
+
+  /**
+   * The options for the build.
+   */
+  options?: Options
+
+  /**
+   * The working directory of the test. It's a relative path to the test directory.
+   *
+   * @default '.'
+   */
+  cwd?: string
+
+  /**
+   * A function that will be called before the build.
+   */
+  beforeBuild?: () => Promise<void>
+}
+
+export async function testBuild({
+  context,
+  files,
+  fixture,
+  options,
+  cwd,
+  beforeBuild,
+}: TestBuildOptions): Promise<{
   outputFiles: string[]
   outputDir: string
   snapshot: string
 }> {
   const { expect } = context
-  const { testName, testDir } = await writeFixtures(context, files)
+  const { testName, testDir } = await writeFixtures(context, files, fixture)
+
+  const workingDirBefore = process.cwd()
 
-  const cwd = process.cwd()
-  process.chdir(testDir)
+  const workingDir = path.join(testDir, cwd || '.')
+  process.chdir(workingDir)
   const resolvedOptions: Options = {
     entry: 'index.ts',
     config: false,
@@ -60,10 +128,11 @@ export async function testBuild(
     silent: true,
     ...options,
   }
+  await beforeBuild?.()
   await build(resolvedOptions)
-  process.chdir(cwd)
+  process.chdir(workingDirBefore)
 
-  const outputDir = path.resolve(testDir, resolvedOptions.outDir!)
+  const outputDir = path.resolve(workingDir, resolvedOptions.outDir!)
   const { files: outputFiles, snapshot } = await expectFilesSnapshot(
     outputDir,
     path.resolve(snapshotsDir, `${testName}.snap.md`),
diff --git a/tsconfig.json b/tsconfig.json
index acafcccd0..a7259d4e4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,5 +17,5 @@
     "skipLibCheck": true
   },
   "include": ["src", "tests"],
-  "exclude": ["**/temp"]
+  "exclude": ["**/temp", "**/fixtures"]
 }
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..31299f155
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+  server: {
+    watch: {
+      ignored: ['**/temp/**'],
+    },
+  },
+})


Reviews:
user: login: sxzz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T03:31:36Z
user: login: sxzz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T04:04:25Z

Review comments:
diff_hunk: @@ -1,5 +1,10 @@
 import { sxzz } from '@sxzz/eslint-config'
 
-export default sxzz({
-  pnpm: true,
-})
+export default sxzz(
+  {
+    pnpm: true,
+  },
+  {
+    ignores: ['**/__fixtures__/**'],
path: eslint.config.js
user: login: sxzz
body: Revert `eslint.config.js`, and rename `__fixtures__` to `fixtures`.
created_at: 2025-05-07T03:02:55Z
author_association: MEMBER
diff_hunk: @@ -23,11 +24,31 @@ test('#61', async (context) => {
         },
       }),
     },
-    {
+    options: {
       external: ['hono/compress', 'hono', 'hono/pretty-json'],
       skipNodeModulesBundle: true,
       target: 'es2022',
       platform: 'node',
     },
-  )
+  })
+})
+
+test('#206', async (context) => {
path: tests/issues.test.ts
user: login: sxzz
body: Move to `issues.test.ts`
created_at: 2025-05-07T03:03:45Z
author_association: MEMBER

PR comments:
user: login: netlify[bot]
created_at: 2025-05-07T01:18:40Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *tsdown* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 9acee1e3efbba8717c82a1f1653fff987e497229 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/tsdown/deploys/681adba03a0d830008213e2f |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-208--tsdown.netlify.app](https://deploy-preview-208--tsdown.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTIwOC0tdHNkb3duLm5ldGxpZnkuYXBwIn0.oOHTJLME-e8ger8fyn9jdanwAOsGqCBWHhDHPrcOMEM)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [tsdown Preview](https://deploy-preview-208--tsdown.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/tsdown/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-05-07T01:18:53Z
body: 
[Open in StackBlitz](https://pkg.pr.new/template/6059a3a3-f6b7-44b8-aa6b-e6629133ecc5)


```
npm i https://pkg.pr.new/tsdown@208
```


_commit: <a href="https://github.com/rolldown/tsdown/runs/41770233723"><code>9acee1e</code></a>_

author_association: NONE