import {describe, test, expect, beforeEach, afterAll} from 'bun:test'
import {mkdirSync, writeFileSync, rmSync, existsSync, readFileSync} from 'fs'
import {join} from 'path'
import LuckyBun from '../../src/bun/lucky.js'
const TEST_DIR = join(process.cwd(), '.test-tmp')
beforeEach(() => {
rmSync(TEST_DIR, {recursive: true, force: true})
mkdirSync(TEST_DIR, {recursive: true})
LuckyBun.manifest = {}
LuckyBun.config = null
LuckyBun.plugins = []
LuckyBun.debug = false
LuckyBun.prod = false
LuckyBun.dev = false
LuckyBun.root = TEST_DIR
})
afterAll(() => {
rmSync(TEST_DIR, {recursive: true, force: true})
})
function createFile(relativePath, content = '') {
const fullPath = join(TEST_DIR, relativePath)
mkdirSync(join(fullPath, '..'), {recursive: true})
writeFileSync(fullPath, content)
return fullPath
}
function readOutput(relativePath) {
return readFileSync(join(TEST_DIR, 'public/assets', relativePath), 'utf-8')
}
async function setupProject(files = {}, configOverrides = {}) {
for (const [path, content] of Object.entries(files)) createFile(path, content)
if (configOverrides && Object.keys(configOverrides).length)
createFile('config/bun.json', JSON.stringify(configOverrides))
LuckyBun.loadConfig()
await LuckyBun.loadPlugins()
}
async function buildCSS(files, configOverrides) {
await setupProject(files, configOverrides)
await LuckyBun.buildCSS()
return readOutput('css/app.css')
}
async function buildJS(files, configOverrides) {
await setupProject(files, configOverrides)
await LuckyBun.buildJS()
return readOutput('js/app.js')
}
describe('flags', () => {
test('sets known flags and ignores undefined values', () => {
LuckyBun.flags({dev: true})
expect(LuckyBun.dev).toBe(true)
LuckyBun.flags({prod: true})
expect(LuckyBun.prod).toBe(true)
LuckyBun.flags({debug: true})
expect(LuckyBun.debug).toBe(true)
LuckyBun.dev = true
LuckyBun.flags({prod: false})
expect(LuckyBun.dev).toBe(true)
expect(LuckyBun.prod).toBe(false)
})
})
describe('deepMerge', () => {
test('deep merges objects, replaces arrays and nulls', () => {
expect(LuckyBun.deepMerge({a: 1, b: 2}, {b: 3, c: 4})).toEqual({
a: 1,
b: 3,
c: 4
})
expect(
LuckyBun.deepMerge({outer: {a: 1, b: 2}}, {outer: {b: 3, c: 4}})
).toEqual({outer: {a: 1, b: 3, c: 4}})
expect(LuckyBun.deepMerge({arr: [1, 2]}, {arr: [3, 4, 5]})).toEqual({
arr: [3, 4, 5]
})
expect(LuckyBun.deepMerge({a: {nested: 1}}, {a: null})).toEqual({a: null})
})
})
describe('loadConfig', () => {
test('uses defaults without a config file', () => {
LuckyBun.loadConfig()
expect(LuckyBun.config.outDir).toBe('public/assets')
expect(LuckyBun.config.watchDirs).toEqual(['src/js', 'src/css', 'src/images', 'src/fonts'])
expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js'])
expect(LuckyBun.config.devServer.port).toBe(3002)
expect(LuckyBun.config.plugins).toEqual({
css: ['aliases', 'cssGlobs'],
js: ['aliases', 'jsGlobs']
})
})
test('merges user config with defaults', () => {
createFile(
'config/bun.json',
JSON.stringify({outDir: 'dist', devServer: {port: 4000}})
)
LuckyBun.loadConfig()
expect(LuckyBun.config.outDir).toBe('dist')
expect(LuckyBun.config.devServer.port).toBe(4000)
expect(LuckyBun.config.devServer.host).toBe('127.0.0.1')
expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js'])
})
test('merges watchDirs from user config', () => {
createFile(
'config/bun.json',
JSON.stringify({watchDirs: ['src/js', 'src/css']})
)
LuckyBun.loadConfig()
expect(LuckyBun.config.watchDirs).toEqual(['src/js', 'src/css'])
})
test('merges listenHost into devServer config', () => {
createFile(
'config/bun.json',
JSON.stringify({devServer: {listenHost: '0.0.0.0'}})
)
LuckyBun.loadConfig()
expect(LuckyBun.config.devServer.listenHost).toBe('0.0.0.0')
expect(LuckyBun.config.devServer.host).toBe('127.0.0.1')
})
test('user can override plugins', () => {
createFile(
'config/bun.json',
JSON.stringify({
plugins: {css: ['cssAliases'], js: ['config/bun/banner.js']}
})
)
LuckyBun.loadConfig()
expect(LuckyBun.config.plugins.css).toEqual(['cssAliases'])
expect(LuckyBun.config.plugins.js).toEqual(['config/bun/banner.js'])
})
})
describe('fingerprint', () => {
test('returns plain filename in dev mode', () => {
expect(LuckyBun.fingerprint('app', '.js', 'content')).toBe('app.js')
})
test('returns consistent, content-dependent hashes in prod mode', () => {
LuckyBun.prod = true
const hash = LuckyBun.fingerprint('app', '.js', 'content')
expect(hash).toMatch(/^app-[a-f0-9]{8}\.js$/)
expect(LuckyBun.fingerprint('app', '.js', 'content')).toBe(hash)
expect(LuckyBun.fingerprint('app', '.js', 'different')).not.toBe(hash)
})
})
describe('IGNORE_PATTERNS', () => {
test('ignores editor artifacts and system files but allows normal files', () => {
const ignores = f => LuckyBun.IGNORE_PATTERNS.some(p => p.test(f))
for (const f of [
'.#file.js',
'file.swp',
'file.swo',
'file.tmp',
'#file.js#',
'.DS_Store',
'12345'
])
expect(ignores(f)).toBe(true)
for (const f of ['app.js', 'styles.css', 'image.png'])
expect(ignores(f)).toBe(false)
})
})
describe('buildAssets', () => {
test('builds JS files', async () => {
await buildJS({'src/js/app.js': 'console.log("test")'})
expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')
expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)
})
test('builds CSS files', async () => {
await buildCSS({'src/css/app.css': 'body { color: pink }'})
expect(LuckyBun.manifest['css/app.css']).toBe('css/app.css')
expect(existsSync(join(TEST_DIR, 'public/assets/css/app.css'))).toBe(true)
})
test('fingerprints in prod mode', async () => {
LuckyBun.prod = true
await setupProject({'src/js/app.js': 'console.log("prod")'})
await LuckyBun.buildJS()
expect(LuckyBun.manifest['js/app.js']).toMatch(/^js\/app-[a-f0-9]{8}\.js$/)
})
test('warns on missing entry point and continues', async () => {
await setupProject()
// No src/js/app.js created — should not throw
await LuckyBun.buildJS()
expect(LuckyBun.manifest['js/app.js']).toBeUndefined()
})
test('accepts a string entry point', async () => {
await setupProject(
{'src/js/app.js': 'console.log("single")'},
{entryPoints: {js: 'src/js/app.js'}}
)
await LuckyBun.buildJS()
expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')
})
test('builds multiple JS entry points', async () => {
await buildJS(
{
'src/js/app.js': 'console.log("app")',
'src/js/admin.js': 'console.log("admin")'
},
{entryPoints: {js: ['src/js/app.js', 'src/js/admin.js']}}
)
expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')
expect(LuckyBun.manifest['js/admin.js']).toBe('js/admin.js')
})
test('builds TypeScript files', async () => {
await setupProject(
{'src/js/app.ts': 'const msg: string = "hello"\nconsole.log(msg)'},
{entryPoints: {js: ['src/js/app.ts']}}
)
await LuckyBun.buildJS()
expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')
expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)
expect(readOutput('js/app.js')).toContain('hello')
})
test('builds TSX files', async () => {
await setupProject(
{
'src/js/app.tsx': [
'function App(): string { return "tsx works" }',
'console.log(App())'
].join('\n')
},
{entryPoints: {js: ['src/js/app.tsx']}}
)
await LuckyBun.buildJS()
expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')
expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)
})
test('builds multiple CSS entry points', async () => {
await buildCSS(
{
'src/css/app.css': 'body { color: red }',
'src/css/admin.css': 'body { color: blue }'
},
{entryPoints: {css: ['src/css/app.css', 'src/css/admin.css']}}
)
expect(LuckyBun.manifest['css/app.css']).toBe('css/app.css')
expect(LuckyBun.manifest['css/admin.css']).toBe('css/admin.css')
})
})
describe('copyStaticAssets', () => {
async function copyAssets(files = {}, config = {}) {
await setupProject(files, config)
await LuckyBun.copyStaticAssets()
}
test('copies images and fonts, preserving nested structure', async () => {
await copyAssets({
'src/images/logo.png': 'fake-image-data',
'src/images/icons/arrow.svg': '',
'src/fonts/Inter.woff2': 'fake-font-data'
})
expect(LuckyBun.manifest['images/logo.png']).toBe('images/logo.png')
expect(LuckyBun.manifest['images/icons/arrow.svg']).toBeDefined()
expect(LuckyBun.manifest['fonts/Inter.woff2']).toBe('fonts/Inter.woff2')
expect(existsSync(join(TEST_DIR, 'public/assets/images/logo.png'))).toBe(
true
)
expect(
existsSync(join(TEST_DIR, 'public/assets/images/icons/arrow.svg'))
).toBe(true)
})
test('fingerprints static assets in prod mode', async () => {
LuckyBun.prod = true
await copyAssets({'src/images/logo.png': 'fake-image-data'})
expect(LuckyBun.manifest['images/logo.png']).toMatch(
/^images\/logo-[a-f0-9]{8}\.png$/
)
})
test('skips missing static directories', async () => {
await copyAssets()
expect(Object.keys(LuckyBun.manifest)).toHaveLength(0)
})
})
describe('cleanOutDir', () => {
test('removes output directory and does not throw if already absent', async () => {
createFile('public/assets/js/old.js', 'old')
await setupProject()
LuckyBun.cleanOutDir()
expect(existsSync(join(TEST_DIR, 'public/assets'))).toBe(false)
expect(() => LuckyBun.cleanOutDir()).not.toThrow()
})
})
describe('writeManifest', () => {
test('writes manifest JSON', async () => {
await setupProject()
LuckyBun.manifest = {'js/app.js': 'js/app-abc123.js'}
await LuckyBun.writeManifest()
const content = readFileSync(
join(TEST_DIR, LuckyBun.config.manifestPath),
'utf-8'
)
expect(JSON.parse(content)).toEqual({'js/app.js': 'js/app-abc123.js'})
})
})
describe('outDir', () => {
test('throws if config not loaded', () => {
LuckyBun.config = null
expect(() => LuckyBun.outDir).toThrow('Config is not loaded')
})
test('returns full path when config loaded', () => {
LuckyBun.loadConfig()
expect(LuckyBun.outDir).toBe(join(TEST_DIR, 'public/assets'))
})
})
describe('loadPlugins', () => {
test('loads default plugins', async () => {
LuckyBun.loadConfig()
await LuckyBun.loadPlugins()
expect(LuckyBun.plugins).toHaveLength(2)
expect(
LuckyBun.plugins.find(p => p.name === 'css-transforms')
).toBeDefined()
expect(LuckyBun.plugins.find(p => p.name === 'js-transforms')).toBeDefined()
})
test('loads no plugins when config is empty', async () => {
createFile('config/bun.json', JSON.stringify({plugins: {}}))
LuckyBun.loadConfig()
await LuckyBun.loadPlugins()
expect(LuckyBun.plugins).toHaveLength(0)
})
test('handles unknown built-in plugin gracefully', async () => {
createFile(
'config/bun.json',
JSON.stringify({plugins: {css: ['nonExistent']}})
)
LuckyBun.loadConfig()
await LuckyBun.loadPlugins()
expect(LuckyBun.plugins).toHaveLength(0)
})
test('loads custom plugin from path', async () => {
createFile(
'config/bun/uppercase.js',
`export default function() {
return content => content.toUpperCase()
}`
)
createFile(
'config/bun.json',
JSON.stringify({plugins: {css: ['config/bun/uppercase.js']}})
)
LuckyBun.loadConfig()
await LuckyBun.loadPlugins()
expect(LuckyBun.plugins).toHaveLength(1)
expect(LuckyBun.plugins[0].name).toBe('css-transforms')
})
})
describe('aliases plugin', () => {
test('replaces $/ references with root path in CSS url()', async () => {
const content = await buildCSS({
'src/css/app.css': [
"body { background: url('$/src/images/bg.png'); }",
".icon { background: url('$/src/images/icon.svg'); }"
].join('\n'),
'src/images/bg.png': 'fake',
'src/images/icon.svg': ''
})
// The alias is resolved and Bun inlines the assets as data URIs
expect(content).not.toContain('$/')
expect(content).toContain('url(')
})
test('replaces $/ references in JS imports', async () => {
const content = await buildJS({
'src/js/app.js': "import utils from '$/lib/utils.js'\nconsole.log(utils)",
'lib/utils.js': 'export default 42'
})
expect(content).not.toContain('$/')
expect(content).toContain('42')
})
test('replaces $/ references in CSS @import', async () => {
const content = await buildCSS({
'src/css/app.css': "@import '$/lib/reset.css';",
'lib/reset.css': '* { margin: 0 }'
})
expect(content).not.toContain('$/')
expect(content).toContain('margin')
})
test('leaves non-alias urls untouched', async () => {
const content = await buildCSS({
'src/css/app.css':
"body { background: url('https://example.com/bg.png'); }"
})
expect(content).toContain('https://example.com/bg.png')
})
test('replaces $/ references in TypeScript imports', async () => {
await setupProject(
{
'src/js/app.ts': "import utils from '$/lib/utils.ts'\nconsole.log(utils)",
'lib/utils.ts': 'const val: number = 99\nexport default val'
},
{entryPoints: {js: ['src/js/app.ts']}}
)
await LuckyBun.buildJS()
const content = readOutput('js/app.js')
expect(content).not.toContain('$/')
expect(content).toContain('99')
})
test('leaves non-alias imports untouched', async () => {
const content = await buildJS({
'src/js/app.js': "import {x} from './utils.js'\nconsole.log(x)",
'src/js/utils.js': 'export const x = 42'
})
expect(content).toContain('42')
})
test('resolves $/ inside prefixed strings like glob:$/', async () => {
const aliases = (await import('../../src/bun/plugins/aliases.js')).default
const transform = aliases({root: '/root'})
const result = transform("import c from 'glob:$/lib/components/*.js'")
expect(result).toBe("import c from 'glob:/root/lib/components/*.js'")
})
test('does not replace $/ inside regex literals', async () => {
const aliases = (await import('../../src/bun/plugins/aliases.js')).default
const transform = aliases({root: '/root'})
const input = "s.replace(/.*components\\//, '').replace(/_component$/, '')"
const result = transform(input)
expect(result).toBe(input)
})
test('does not match $/ preceded by a word character', async () => {
const content = await buildJS({
'src/js/app.js': [
"const el = document.querySelector('div')",
"const path = '/api/test'",
'console.log(el, path)'
].join('\n')
})
expect(content).not.toContain(TEST_DIR)
})
})
describe('cssGlobs plugin', () => {
test('expands glob @import with flat wildcard', async () => {
const content = await buildCSS({
'src/css/app.css': "@import './components/*.css';",
'src/css/components/button.css': '.button { color: red }',
'src/css/components/card.css': '.card { color: blue }'
})
expect(content).toContain('.button')
expect(content).toContain('.card')
})
test('expands glob @import with ** recursive wildcard', async () => {
const content = await buildCSS({
'src/css/app.css': "@import './components/**/*.css';",
'src/css/components/button.css': '.button { color: red }',
'src/css/components/forms/input.css': '.input { color: green }',
'src/css/components/forms/select.css': '.select { color: blue }'
})
expect(content).toContain('.button')
expect(content).toContain('.input')
expect(content).toContain('.select')
})
test('does not import the file itself', async () => {
const content = await buildCSS({
'src/css/app.css': "@import './*.css';",
'src/css/other.css': '.other { color: red }'
})
expect(content).toContain('.other')
})
test('handles glob matching no files', async () => {
await buildCSS({
'src/css/app.css': "@import './empty/**/*.css';",
'src/css/empty/.gitkeep': ''
})
})
test('preserves non-glob imports', async () => {
const content = await buildCSS({
'src/css/app.css':
"@import './reset.css';\n@import './components/*.css';",
'src/css/reset.css': '* { margin: 0 }',
'src/css/components/button.css': '.button { color: red }'
})
expect(content).toContain('margin')
expect(content).toContain('.button')
})
test('expands globs in deterministic sorted order', async () => {
const content = await buildCSS({
'src/css/app.css': "@import './components/*.css';",
'src/css/components/zebra.css': '.zebra { order: 3 }',
'src/css/components/alpha.css': '.alpha { order: 1 }',
'src/css/components/middle.css': '.middle { order: 2 }'
})
const alphaPos = content.indexOf('.alpha')
const middlePos = content.indexOf('.middle')
const zebraPos = content.indexOf('.zebra')
expect(alphaPos).toBeLessThan(middlePos)
expect(middlePos).toBeLessThan(zebraPos)
})
})
describe('jsGlobs plugin', () => {
const jsGlobsConfig = {plugins: {js: ['jsGlobs']}}
function jsApp(...lines) {
return {'src/js/app.js': lines.join('\n')}
}
async function buildJSGlobs(files) {
return buildJS(files, jsGlobsConfig)
}
test('expands glob import into named exports', async () => {
const content = await buildJSGlobs({
...jsApp(
"import components from 'glob:./components/*.js'",
'console.log(components)'
),
'src/js/components/modal.js': 'export default function modal() {}',
'src/js/components/dropdown.js': 'export default function dropdown() {}'
})
expect(content).toContain('modal')
expect(content).toContain('dropdown')
})
test('expands recursive glob with relative path keys', async () => {
const content = await buildJSGlobs({
...jsApp(
"import controllers from 'glob:./controllers/**/*.js'",
'console.log(Object.keys(controllers))'
),
'src/js/controllers/nav.js': 'export default function nav() {}',
'src/js/controllers/forms/input.js': 'export default function input() {}'
})
expect(content).toContain('nav')
expect(content).toContain('forms/input')
})
test('avoids naming clashes for same-named files in different dirs', async () => {
const content = await buildJSGlobs({
...jsApp(
"import modules from 'glob:./components/**/*.js'",
'console.log(Object.keys(modules))'
),
'src/js/components/nav.js': 'export default function nav() {}',
'src/js/components/admin/nav.js': 'export default function adminNav() {}'
})
expect(content).toContain('nav')
expect(content).toContain('admin/nav')
})
test('handles glob matching no files', async () => {
const content = await buildJSGlobs({
...jsApp(
"import components from 'glob:./components/*.js'",
'console.log(components)'
),
'src/js/components/.gitkeep': ''
})
expect(content).toBeDefined()
})
test('leaves non-glob imports untouched', async () => {
const content = await buildJSGlobs({
...jsApp(
"import {something} from './utils.js'",
'console.log(something)'
),
'src/js/utils.js': 'export const something = 42'
})
expect(content).toContain('42')
})
test('handles multiple glob imports', async () => {
const content = await buildJSGlobs({
...jsApp(
"import data from 'glob:./data/*.js'",
"import stores from 'glob:./stores/*.js'",
'console.log(data, stores)'
),
'src/js/data/counter.js': 'export default function counter() {}',
'src/js/stores/auth.js': 'export default function auth() {}'
})
expect(content).toContain('counter')
expect(content).toContain('auth')
})
test('avoids variable collisions across multiple globs with same filenames', async () => {
const content = await buildJSGlobs({
...jsApp(
"import components from 'glob:./components/*.js'",
"import widgets from 'glob:./widgets/*.js'",
'console.log(components, widgets)'
),
'src/js/components/theme.js':
'export default function componentTheme() { return "component" }',
'src/js/widgets/theme.js':
'export default function widgetTheme() { return "widget" }'
})
expect(content).toContain('component')
expect(content).toContain('widget')
})
test('expands globs in deterministic sorted order', async () => {
const content = await buildJSGlobs({
...jsApp(
"import components from 'glob:./components/*.js'",
'for (const [k, v] of Object.entries(components)) console.log(k)'
),
'src/js/components/zebra.js': 'export default function zebra() {}',
'src/js/components/alpha.js': 'export default function alpha() {}',
'src/js/components/middle.js': 'export default function middle() {}'
})
const alphaPos = content.indexOf('alpha')
const middlePos = content.indexOf('middle')
const zebraPos = content.indexOf('zebra')
expect(alphaPos).toBeLessThan(middlePos)
expect(middlePos).toBeLessThan(zebraPos)
})
})
describe('plugin pipeline', () => {
test('css plugins run in configured order', async () => {
const content = await buildCSS({
'src/css/app.css':
"@import './components/*.css';\nbody { background: url('$/src/images/bg.png'); }",
'src/css/components/button.css': '.button { color: red }',
'src/images/bg.png': 'fake'
})
expect(content).not.toContain('$/')
expect(content).toContain('.button')
})
test('disabling all plugins still builds valid output', async () => {
const css = await buildCSS(
{'src/css/app.css': 'body { color: red }'},
{plugins: {}}
)
expect(css).toContain('color')
const js = await buildJS(
{'src/js/app.js': 'console.log("hello")'},
{plugins: {}}
)
expect(js).toContain('hello')
})
})
describe('full build', () => {
test('runs the complete build pipeline', async () => {
await setupProject({
'src/js/app.js': 'console.log("built")',
'src/css/app.css': 'body { color: red }',
'src/images/logo.png': 'fake-image'
})
LuckyBun.cleanOutDir()
await LuckyBun.copyStaticAssets()
await LuckyBun.buildJS()
await LuckyBun.buildCSS()
await LuckyBun.writeManifest()
expect(LuckyBun.manifest['js/app.js']).toBeDefined()
expect(LuckyBun.manifest['css/app.css']).toBeDefined()
expect(LuckyBun.manifest['images/logo.png']).toBeDefined()
expect(existsSync(join(TEST_DIR, LuckyBun.config.manifestPath))).toBe(true)
})
test('clean build removes previous output', async () => {
createFile('public/assets/js/stale.js', 'old stuff')
await setupProject({'src/js/app.js': 'console.log("fresh")'})
LuckyBun.cleanOutDir()
await LuckyBun.buildJS()
expect(existsSync(join(TEST_DIR, 'public/assets/js/stale.js'))).toBe(false)
expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)
})
})
describe('prettyManifest', () => {
test('formats manifest entries and handles empty manifest', () => {
LuckyBun.manifest = {
'js/app.js': 'js/app-abc123.js',
'css/app.css': 'css/app-def456.css'
}
const output = LuckyBun.prettyManifest()
expect(output).toContain('js/app.js → js/app-abc123.js')
expect(output).toContain('css/app.css → css/app-def456.css')
LuckyBun.manifest = {}
expect(LuckyBun.prettyManifest()).toContain('\n')
})
})