import path from 'node:path' import fs from 'node:fs' import { expect, test } from 'vitest' import waitForExpect from 'wait-for-expect' import { debouncePromise } from '../src/utils' import { getTestName, run } from './utils' test('simple', async () => { const { output, outFiles } = await run(getTestName(), { 'input.ts': `import foo from './foo';export default foo`, 'foo.ts': `export default 'foo'`, }) expect(output).toMatchSnapshot() expect(outFiles).toEqual(['input.js']) }) test('should not filter unknown directives during bundle', async () => { const { output, outFiles } = await run(getTestName(), { 'input.ts': `'use client'\nexport default 'foo'`, }) expect(output).toContain('use client') expect(outFiles).toEqual(['input.js']) }) test('multiple formats', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': ` export const a = 1 `, }, { flags: ['--format', 'esm,cjs,iife'], }, ) expect(outFiles).toEqual(['input.global.js', 'input.js', 'input.mjs']) }) test('multiple formats and pkg.type is module', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': ` export const a = 1 `, 'package.json': JSON.stringify({ type: 'module' }), }, { flags: ['--format', 'esm,cjs,iife'], }, ) expect(outFiles).toEqual(['input.cjs', 'input.global.js', 'input.js']) }) test('minify', async () => { const { output, outFiles } = await run( getTestName(), { 'input.ts': ` export function foo() { return 'foo' } `, }, { flags: ['--minify'], }, ) expect(output).toContain(`return"foo"`) expect(outFiles).toEqual(['input.js']) }) test('minify with es5 target', async () => { const { output, outFiles } = await run( getTestName(), { 'input.ts': ` export function foo() { return 'foo' } `, }, { flags: ['--minify', '--target', 'es5'], }, ) expect(output).toContain(`return"foo"`) expect(outFiles).toEqual(['input.js']) }) test('env flag', async () => { const { output, outFiles } = await run( getTestName(), { 'input.ts': ` export const env = process.env.NODE_ENV `, }, { flags: ['--env.NODE_ENV', 'production'], }, ) expect(output).toContain('var env = "production"') expect(outFiles).toEqual(['input.js']) }) test('node protocol', async () => { const { output } = await run(getTestName(), { 'input.ts': `import fs from 'node:fs'; console.log(fs)`, }) expect(output).toMatchSnapshot() expect(output).not.contain('node:fs') }) test("don't remove node protocol", async () => { const { output } = await run(getTestName(), { 'input.ts': `import fs from 'node:fs'; console.log(fs)`, 'tsup.config.ts': ` export default { removeNodeProtocol: false, }`, }) expect(output).toMatchSnapshot() expect(output).contain('node:fs') }) test('external', async () => { const { output } = await run(getTestName(), { 'input.ts': `export {foo} from 'foo' export {bar} from 'bar' export {baz} from 'baz' export {qux} from 'qux' `, 'node_modules/foo/index.ts': `export const foo = 'foo'`, 'node_modules/foo/package.json': `{"name":"foo","version":"0.0.0"}`, 'node_modules/bar/index.ts': `export const bar = 'bar'`, 'node_modules/bar/package.json': `{"name":"bar","version":"0.0.0"}`, 'node_modules/baz/index.ts': `export const baz = 'baz'`, 'node_modules/baz/package.json': `{"name":"baz","version":"0.0.0"}`, 'node_modules/qux/index.ts': `export const qux = 'qux'`, 'node_modules/qux/package.json': `{"name":"qux","version":"0.0.0"}`, 'another/package.json': `{"name":"another-pkg","dependencies":{"qux":"0.0.0"}}`, 'tsup.config.ts': ` export default { external: [/f/, 'bar', 'another/package.json'] } `, }) expect(output).toMatchSnapshot() }) test('noExternal are respected when skipNodeModulesBundle is true', async () => { const { output } = await run(getTestName(), { 'input.ts': `export {foo} from 'foo' export {bar} from 'bar' export {baz} from 'baz' `, 'node_modules/foo/index.ts': `export const foo = 'foo'`, 'node_modules/foo/package.json': `{"name":"foo","version":"0.0.0"}`, 'node_modules/bar/index.ts': `export const bar = 'bar'`, 'node_modules/bar/package.json': `{"name":"bar","version":"0.0.0"}`, 'node_modules/baz/index.ts': `export const baz = 'baz'`, 'node_modules/baz/package.json': `{"name":"baz","version":"0.0.0"}`, 'tsup.config.ts': ` export default { skipNodeModulesBundle: true, noExternal: [/foo/] } `, }) expect(output).toContain(`var foo = "foo"`) expect(output).not.toContain(`var bar = "bar"`) expect(output).not.toContain(`var baz = "baz"`) }) test('disable code splitting to get proper module.exports =', async () => { const { output } = await run( getTestName(), { 'input.ts': `export = 123`, }, { flags: ['--no-splitting'], }, ) expect(output).toMatchSnapshot() }) test('onSuccess', async () => { const { logs } = await run( getTestName(), { 'input.ts': "console.log('test');", }, { flags: ['--onSuccess', 'echo hello && echo world'], }, ) expect(logs.includes('hello')).toEqual(true) expect(logs.includes('world')).toEqual(true) }) test('onSuccess: use a function from config file', async () => { const { logs } = await run(getTestName(), { 'input.ts': "console.log('test');", 'tsup.config.ts': ` export default { onSuccess: async () => { console.log('hello') await new Promise((resolve) => { setTimeout(() => { console.log('world') resolve('') }, 1_000) }) } }`, }) expect(logs.includes('hello')).toEqual(true) expect(logs.includes('world')).toEqual(true) }) test(`transform import.meta.url in cjs format`, async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `export default import.meta.url`, }, { flags: ['--shims'], }, ) expect(await getFileContent('dist/input.js')).toContain('getImportMetaUrl') }) test(`transform __dirname and __filename in esm format`, async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `export const a = __dirname export const b = __filename `, }, { flags: ['--format', 'esm', '--shims'], }, ) const code = await getFileContent('dist/input.mjs') expect(code).toContain('getFilename') expect(code).toContain('getDirname') }) test('debounce promise', async () => { try { const equal = (a: T, b: T) => { const result = a === b if (!result) throw new Error(`${a} !== ${b}`) } const sleep = (n: number = Math.trunc(Math.random() * 50) + 20) => new Promise((resolve) => setTimeout(resolve, n)) let n = 0 const debounceFunction = debouncePromise( async () => { await sleep() ++n }, 100, (err: any) => { expect.fail(err.message) }, ) expect(n).toEqual(0) debounceFunction() debounceFunction() debounceFunction() debounceFunction() await waitForExpect(() => { equal(n, 1) }) await sleep(100) expect(n).toEqual(1) debounceFunction() await waitForExpect(() => { equal(n, 2) }) } catch (error: any) { return expect.fail(error.message) } }) test('exclude dependencies', async () => { const { getFileContent } = await run(getTestName(), { 'input.ts': `export {foo} from 'foo';export {nested} from 'foo/nested'`, 'package.json': `{"dependencies":{"foo":"0.0.0"}}`, 'node_modules/foo/index.js': `export const foo = 'foo'`, 'node_modules/foo/package.json': `{"name":"foo"}`, }) const contents = await getFileContent('dist/input.js') expect(contents).toContain('require("foo")') expect(contents).toContain('require("foo/nested")') }) test('code splitting in cjs format', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `const foo = () => import('./foo');export {foo}`, 'another-input.ts': `const foo = () => import('./foo');export {foo}`, 'foo.ts': `export const foo = 'bar'`, }, { flags: ['another-input.ts', '--splitting'] }, ) expect(await getFileContent('dist/input.js')).toMatchSnapshot() expect(await getFileContent('dist/another-input.js')).toMatchSnapshot() }) test('esbuild metafile', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': `export const foo = 1` }, { flags: ['--metafile'], }, ) expect(outFiles).toEqual(['input.js', 'metafile-cjs.json']) }) test('multiple entry with the same base name', async () => { const { outFiles } = await run( getTestName(), { 'src/input.ts': `export const foo = 1`, 'src/bar/input.ts': `export const bar = 2`, }, { entry: ['src/input.ts', 'src/bar/input.ts'], }, ) expect(outFiles).toEqual(['bar/input.js', 'input.js']) }) test('windows: backslash in entry', async () => { const { outFiles } = await run( getTestName(), { 'src/input.ts': `export const foo = 1` }, { entry: [String.raw`src\input.ts`], }, ) expect(outFiles).toEqual(['input.js']) }) test('emit declaration files only', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': `export const foo = 1`, }, { flags: ['--dts-only'], }, ) expect(outFiles).toEqual(['input.d.ts']) }) test('decorator metadata', async () => { const { getFileContent } = await run(getTestName(), { 'input.ts': ` function Injectable() {} @Injectable() export class Foo { @Field() bar() {} } `, 'tsconfig.json': `{ "compilerOptions": { "emitDecoratorMetadata": true, } }`, }) const contents = await getFileContent('dist/input.js') expect(contents).toContain(`_ts_metadata("design:type", Function)`) }) test('inject style', async () => { const { outFiles, output } = await run( getTestName(), { 'input.ts': `import './style.css'`, 'style.css': `.hello { color: red }`, }, { flags: ['--inject-style', '--minify'], }, ) expect(outFiles).toEqual(['input.js']) expect(output).toContain('.hello{color:red}') }) test('inject style in multi formats', async () => { const { outFiles, getFileContent } = await run( getTestName(), { 'input.ts': `export * from './App.svelte'`, 'App.svelte': ` {msg} `, }, { flags: ['--inject-style', '--minify', '--format', 'esm,cjs,iife'], }, ) expect(outFiles).toEqual(['input.global.js', 'input.js', 'input.mjs']) for (const file of outFiles) { expect(await getFileContent(`dist/${file}`)).toContain('{color:red}') } }) test('shebang', async () => { const { outDir } = await run( getTestName(), { 'a.ts': `#!/usr/bin/env node\bconsole.log('a')`, 'b.ts': `console.log('b')`, }, { entry: ['a.ts', 'b.ts'], }, ) if (process.platform === 'win32') { return } expect(() => { fs.accessSync(path.join(outDir, 'a.js'), fs.constants.X_OK) }).not.toThrow() expect(() => { fs.accessSync(path.join(outDir, 'b.js'), fs.constants.X_OK) }).toThrow() }) test('es5 target', async () => { const { output, outFiles } = await run( getTestName(), { 'input.ts': ` export class Foo { hi (): void { let a = () => 'foo' console.log(a()) } } `, }, { flags: ['--target', 'es5'], }, ) expect(output).toMatch(/_create_class/) expect(outFiles).toEqual(['input.js']) }) test('es5 minify', async () => { const { getFileContent, outFiles } = await run( getTestName(), { 'input.ts': ` export class Foo { hi (): void { let a = () => 'foo' console.log(a()) } } `, }, { flags: [ '--target', 'es5', '--format', 'iife', '--globalName', 'FooAPI', '--minify', ], }, ) expect(outFiles).toEqual(['input.global.js']) const iifeBundle = await getFileContent('dist/input.global.js') expect(iifeBundle).toMatch(/var FooAPI/) expect(iifeBundle).not.toMatch(/createClass/) }) test('multiple targets', async () => { const { output, outFiles } = await run( getTestName(), { 'input.ts': ` export const answer = 42 `, }, { entry: ['input.ts'], flags: ['--target', 'es2020,chrome58,firefox57,safari11,edge16'], }, ) expect(output).toMatchSnapshot() expect(outFiles).toEqual(['input.js']) }) test('native-node-module plugin should handle *.node(.js) import properly', async () => { await run( getTestName(), { 'input.tsx': `export * from './hi.node'`, 'hi.node.js': `export const hi = 'hi'`, }, { entry: ['input.tsx'], }, ) }) test('proper sourcemap sources path when swc is enabled', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `export const hi = 'hi'`, 'tsconfig.json': JSON.stringify({ compilerOptions: { emitDecoratorMetadata: true, }, }), }, { entry: ['input.ts'], flags: ['--sourcemap'], }, ) const map = await getFileContent('dist/input.js.map') expect(map).toContain(`["../input.ts"]`) }) // Fixing https://github.com/evanw/esbuild/issues/1794 test('use rollup for treeshaking', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': ` export { useRoute } from 'vue-router' `, }, { entry: ['input.ts'], flags: ['--treeshake', '--external', 'vue', '--format', 'esm'], }, ) expect(await getFileContent('dist/input.mjs')).toContain( `function useRoute(_name) { return inject(routeLocationKey); }`, ) }) test('use rollup for treeshaking --format cjs', async () => { const { getFileContent } = await run( getTestName(), { 'package.json': `{ "dependencies": { "react-select": "5.7.0", "react": "17.0.2", "react-dom": "17.0.2" } }`, 'input.tsx': ` import ReactSelect from 'react-select' export const Component = (props: {}) => { return }; `, 'tsconfig.json': `{ "compilerOptions": { "baseUrl": ".", "esModuleInterop": true, "isolatedModules": true, "jsx": "react-jsx", "lib": ["dom", "dom.iterable", "esnext"], "module": "esnext", "moduleResolution": "node", "noEmit": true, "rootDir": ".", "skipLibCheck": true, "sourceMap": true, "strict": true, "target": "es6", "importHelpers": true, "outDir": "dist" } }`, }, { entry: ['input.tsx'], flags: ['--treeshake', '--target', 'es2022', '--format', 'cjs'], }, ) expect(await getFileContent('dist/input.js')).toContain( `jsxRuntime.jsx(ReactSelect__default.default`, ) }) test('custom output extension', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': `export const foo = [1,2,3]`, 'tsup.config.ts': `export default { outExtension({ format }) { return { js: '.' + format + '.js' } } }`, }, { entry: ['input.ts'], flags: ['--format', 'esm,cjs'], }, ) expect(outFiles).toMatchInlineSnapshot(` [ "input.cjs.js", "input.esm.js", ] `) }) test('custom config file', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': `export const foo = [1,2,3]`, 'custom.config.ts': `export default { format: ['esm'] }`, }, { entry: ['input.ts'], flags: ['--config', 'custom.config.ts'], }, ) expect(outFiles).toMatchInlineSnapshot(` [ "input.mjs", ] `) }) test('use an object as entry from cli flag', async () => { const { outFiles } = await run( getTestName(), { 'input.ts': `export const foo = [1,2,3]`, }, { flags: ['--entry.foo', 'input.ts'], }, ) expect(outFiles).toMatchInlineSnapshot(` [ "foo.js", ] `) }) test('remove unused code', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `if (import.meta.foo) { console.log(1) } else { console.log(2) }`, 'tsup.config.ts': `export default { define: { 'import.meta.foo': 'false' }, treeshake: true }`, }, {}, ) expect(await getFileContent('dist/input.js')).not.toContain('console.log(1)') }) test('treeshake should work with hashbang', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': '#!/usr/bin/node\nconsole.log(123)', }, { flags: ['--treeshake'], }, ) expect(await getFileContent('dist/input.js')).toMatchInlineSnapshot(` "#!/usr/bin/node 'use strict'; // input.ts console.log(123); " `) }) test('support target in tsconfig.json', async () => { const { getFileContent } = await run( getTestName(), { 'input.ts': `await import('./foo')`, 'foo.ts': `export default 'foo'`, 'tsconfig.json': `{ "compilerOptions": { "baseUrl":".", "target": "esnext" } }`, }, { flags: ['--format', 'esm'], }, ) expect(await getFileContent('dist/input.mjs')).contains('await import(') }) test('override target in tsconfig.json', async () => { await expect( run( getTestName(), { 'input.ts': `await import('./foo')`, 'foo.ts': `export default 'foo'`, 'tsconfig.json': `{ "compilerOptions": { "baseUrl":".", "target": "esnext" } }`, }, { flags: ['--format', 'esm', '--target', 'es2018'], }, ), ).rejects.toThrowError( `Top-level await is not available in the configured target environment ("es2018")`, ) }) test(`should generate export {} when there are no exports in source file`, async () => { const { outFiles, getFileContent } = await run(getTestName(), { 'input.ts': `const a = 'a'`, 'tsconfig.json': `{ "compilerOptions": { "baseUrl":".", "target": "esnext", } }`, 'tsup.config.ts': ` export default { entry: ['src/input.ts'], format: 'esm', dts: true } `, }) expect(outFiles).toEqual(['input.d.mts', 'input.mjs']) expect(await getFileContent('dist/input.d.mts')).toMatch(/export {\s*}/) }) test('custom inject style function - sync', async () => { const { outFiles, getFileContent } = await run(getTestName(), { 'input.ts': `import './style.css'`, 'style.css': `.hello { color: red }`, 'tsup.config.ts': ` export default { entry: ['src/input.ts'], minify: true, format: ['esm', 'cjs'], injectStyle: (css) => { return "__custom_inject_style__(" + css +")"; } }`, }) expect(outFiles).toEqual(['input.js', 'input.mjs']) expect(await getFileContent('dist/input.mjs')).toContain( '__custom_inject_style__(`.hello{color:red}\n`)', ) expect(await getFileContent('dist/input.js')).toContain( '__custom_inject_style__(`.hello{color:red}\n`)', ) }) test('custom inject style function - async', async () => { const { outFiles, getFileContent } = await run(getTestName(), { 'input.ts': `import './style.css'`, 'style.css': `.hello { color: red }`, 'tsup.config.ts': ` export default { entry: ['src/input.ts'], minify: true, format: ['esm', 'cjs'], injectStyle: async (css) => { await new Promise(resolve => setTimeout(resolve, 100)); return "__custom_async_inject_style__(" + css +")"; } }`, }) expect(outFiles).toEqual(['input.js', 'input.mjs']) expect(await getFileContent('dist/input.mjs')).toContain( '__custom_async_inject_style__(`.hello{color:red}\n`)', ) expect(await getFileContent('dist/input.js')).toContain( '__custom_async_inject_style__(`.hello{color:red}\n`)', ) }) test('preserve top-level variable for IIFE format', async () => { const { outFiles, getFileContent } = await run(getTestName(), { 'input.ts': `export default 'foo'`, 'tsup.config.ts': ` export default { entry: ['src/input.ts'], globalName: 'globalFoo', minify: 'terser', format: ['iife'] }`, }) expect(outFiles).toEqual(['input.global.js']) expect(await getFileContent('dist/input.global.js')).toMatch(/globalFoo\s*=/) }) test('should load postcss esm config', async () => { const { outFiles, getFileContent } = await run(getTestName(), { 'input.ts': ` import './foo.css' `, 'package.json': `{ "type": "module" }`, 'postcss.config.js': ` export default { plugins: {'postcss-simple-vars': {}} } `, 'foo.css': ` $color: blue; .foo { color: $color; } `, }) expect(outFiles).toEqual(['input.cjs', 'input.css']) expect(await getFileContent('dist/input.css')).toContain('color: blue;') }) test('generate sourcemap with --treeshake', async () => { const sourceCode = 'export function getValue(val: any){ return val; }' const { outFiles, getFileContent } = await run( getTestName(), { 'src/input.ts': sourceCode, }, { entry: ['src/input.ts'], flags: ['--treeshake', '--sourcemap', '--format=cjs,esm,iife'], }, ) expect(outFiles.length).toBe(6) await Promise.all( outFiles .filter((fileName) => fileName.endsWith('.map')) .map(async (sourceMapFile) => { const sourceMap = await getFileContent(`dist/${sourceMapFile}`).then( (rawContent) => JSON.parse(rawContent), ) expect(sourceMap.sources[0]).toBe('../src/input.ts') expect(sourceMap.sourcesContent[0]).toBe(sourceCode) const outputFileName = sourceMapFile.replace('.map', '') expect(sourceMap.file).toBe(outputFileName) }), ) })