feat: add vite-layers
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveLayerStack } from '../src/config'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
const names = (s: { layers: { name: string }[] }) => s.layers.map(l => l.name)
|
||||
|
||||
describe('resolveLayerStack', () => {
|
||||
it('orders the stack project-first (layers[0] = project), then extends depth-first', async () => {
|
||||
const stack = await resolveLayerStack(fixture('stack/app'))
|
||||
expect(names(stack)).toEqual(['app', 'base', 'core'])
|
||||
expect(stack.layers[0].name).toBe('app')
|
||||
})
|
||||
|
||||
it('merges configs with project winning on key collision (defu first-wins)', async () => {
|
||||
const { merged } = await resolveLayerStack(fixture('stack/app'))
|
||||
const features = merged.features as Record<string, unknown>
|
||||
expect(features.shared).toBe('app') // app overrides base overrides core
|
||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
||||
})
|
||||
|
||||
it('resolves srcDir per layer (default "src")', async () => {
|
||||
const stack = await resolveLayerStack(fixture('stack/app'))
|
||||
expect(stack.layers[0].srcDir).toBe(toPosix(resolve(fixture('stack/app'), 'src')))
|
||||
})
|
||||
|
||||
it('dedupes a diamond by rootDir (shared base appears once, first-wins position)', async () => {
|
||||
const stack = await resolveLayerStack(fixture('diamond/app'))
|
||||
expect(names(stack)).toEqual(['app', 'b', 'd', 'c'])
|
||||
expect(names(stack).filter(n => n === 'd')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('survives a cycle (A→B→A) without stack overflow [improvement over raw c12]', async () => {
|
||||
const stack = await resolveLayerStack(fixture('cycle/x'))
|
||||
expect(names(stack)).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
it('auto-scans layers/* with descending priority (Z > A / higher numeric prefix)', async () => {
|
||||
const stack = await resolveLayerStack(fixture('autoscan'))
|
||||
// project first, then 2.z-layer before 1.a-layer (descending sort)
|
||||
expect(names(stack)).toEqual(['root', '2.z-layer', '1.a-layer'])
|
||||
})
|
||||
|
||||
it('applies per-layer $production/$development overrides by Vite mode', async () => {
|
||||
const dev = await resolveLayerStack(fixture('env/app'), { mode: 'development' })
|
||||
const prod = await resolveLayerStack(fixture('env/app'), { mode: 'production' })
|
||||
expect((dev.merged.features as Record<string, unknown>).flag).toBe('dev') // no $development block
|
||||
expect((prod.merged.features as Record<string, unknown>).flag).toBe('prod') // $production wins
|
||||
expect((prod.merged.features as Record<string, unknown>).shared).toBe(true) // base flags preserved
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { configWatchPlugin, featuresRuntimePlugin } from '../src/dev'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
function mockServer() {
|
||||
const watcher = new EventEmitter() as EventEmitter & { add: (paths: string[]) => void }
|
||||
watcher.add = vi.fn()
|
||||
const restart = vi.fn()
|
||||
const server = { watcher, restart, config: { logger: { info: vi.fn() } } }
|
||||
return { server, watcher, restart }
|
||||
}
|
||||
|
||||
const callConfigureServer = (plugin: { configureServer?: unknown }, server: unknown) =>
|
||||
(plugin.configureServer as (s: unknown) => void)(server)
|
||||
|
||||
describe('configWatchPlugin', () => {
|
||||
it('applies only in serve mode', () => {
|
||||
expect(configWatchPlugin([]).apply).toBe('serve')
|
||||
})
|
||||
|
||||
it('watches layer config files and restarts on change', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app'), fixture('stack/base')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
expect(watcher.add).toHaveBeenCalled()
|
||||
watcher.emit('change', resolve(fixture('stack/base'), 'app.config.ts'))
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores unrelated file changes', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const runTransform = (
|
||||
plugin: { transform?: unknown },
|
||||
code: string,
|
||||
id = '/app/src/x.ts',
|
||||
): { code: unknown; map?: unknown } | null => {
|
||||
const t = plugin.transform as
|
||||
| ((this: unknown, c: string, i: string) => { code: unknown; map?: unknown } | null)
|
||||
| undefined
|
||||
return t ? t.call({}, code, id) : null
|
||||
}
|
||||
|
||||
describe('featuresRuntimePlugin', () => {
|
||||
it('applies only in serve mode', () => {
|
||||
expect(featuresRuntimePlugin({}).apply).toBe('serve')
|
||||
})
|
||||
|
||||
it('prepends a module-local __FEATURES__ with a rolldown-generated sourcemap', () => {
|
||||
const out = runTransform(featuresRuntimePlugin({ billing: true }), 'export const x = __FEATURES__.billing')
|
||||
const code = String(out?.code)
|
||||
expect(code).toContain('const __FEATURES__={"billing":true};')
|
||||
expect(code).toContain('export const x = __FEATURES__.billing')
|
||||
expect((out?.map as { mappings?: string })?.mappings).toBeTruthy() // real sourcemap
|
||||
})
|
||||
|
||||
it('ignores property access (_ctx.__FEATURES__) and node_modules', () => {
|
||||
const p = featuresRuntimePlugin({ billing: true })
|
||||
expect(runTransform(p, 'const a = _ctx.__FEATURES__.billing')).toBeNull()
|
||||
expect(runTransform(p, 'export const x = __FEATURES__.billing', '/x/node_modules/y.js')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'root' }
|
||||
@@ -0,0 +1 @@
|
||||
export default { features: { a: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { features: { z: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'x', extends: ['../y'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'y', extends: ['../x'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'app', extends: ['../b', '../c'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'b', extends: ['../d'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'c', extends: ['../d'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'd', features: { tags: ['d'] } }
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
features: { flag: 'dev', shared: true },
|
||||
$production: { features: { flag: 'prod' } },
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
features: {
|
||||
billing: false,
|
||||
nested: { enabled: false, deep: { on: true } },
|
||||
'kebab-flag': true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
HIGH_LOGO
|
||||
@@ -0,0 +1 @@
|
||||
LOW_ICON
|
||||
@@ -0,0 +1 @@
|
||||
LOW_LOGO
|
||||
@@ -0,0 +1 @@
|
||||
LOW_SHARED
|
||||
@@ -0,0 +1 @@
|
||||
<!-- base Footer -->
|
||||
@@ -0,0 +1 @@
|
||||
<!-- base Header -->
|
||||
@@ -0,0 +1 @@
|
||||
export const card = 'base'
|
||||
@@ -0,0 +1 @@
|
||||
<!-- brand Header (override) -->
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'app', extends: ['../base'], features: { shared: 'app', app: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'base', extends: ['../core'], features: { shared: 'base', base: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'core', features: { shared: 'core', core: true }, vite: { define: { LVL: '"core"' } } }
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
extends: ['../base'],
|
||||
tsConfig: { compilerOptions: { strict: false, lib: ['ESNext'] } },
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'base', tsConfig: { compilerOptions: { types: ['node'] } } }
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLayerHooks, registerLayerHooks } from '../src/hooks'
|
||||
import type { Layer, LayerStack } from '../src/types'
|
||||
|
||||
const fakeStack = (): LayerStack => ({ merged: {}, layers: [] })
|
||||
|
||||
describe('layer hooks', () => {
|
||||
it('accumulates layer hooks base-first, then programmatic, and runs serially', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const order: string[] = []
|
||||
// layers are high→low; registration is base-first (reversed), programmatic last.
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('high') } } },
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers, { 'layers:resolved': () => void order.push('prog') })
|
||||
await hooks.callHook('layers:resolved', fakeStack())
|
||||
expect(order).toEqual(['low', 'high', 'prog'])
|
||||
})
|
||||
|
||||
it('handlers mutate the shared argument (mutation-style)', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
{ config: { hooks: { 'layers:resolved': s => void ((s.merged.features ??= {}).x = 1) } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers)
|
||||
const stack = fakeStack()
|
||||
await hooks.callHook('layers:resolved', stack)
|
||||
expect((stack.merged.features as Record<string, unknown>).x).toBe(1)
|
||||
})
|
||||
|
||||
it('awaits async handlers serially', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const order: string[] = []
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
// high layer (registered last): async, must still complete before callHook resolves
|
||||
{ config: { hooks: { 'layers:resolved': async () => { await Promise.resolve(); order.push('high') } } } },
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers)
|
||||
await hooks.callHook('layers:resolved', fakeStack())
|
||||
expect(order).toEqual(['low', 'high'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Plugin, UserConfig, UserConfigFnObject } from 'vite'
|
||||
import { buildViteConfig, dedupePlugins } from '../src/kit'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p).replace(/\\/g, '/')
|
||||
const env = { command: 'build', mode: 'production', isSsrBuild: false, isPreview: false } as const
|
||||
|
||||
async function build(appDir: string): Promise<UserConfig> {
|
||||
const fn = (await buildViteConfig(appDir)) as UserConfigFnObject
|
||||
return (await fn(env)) as UserConfig
|
||||
}
|
||||
|
||||
describe('buildViteConfig', () => {
|
||||
it('exposes merged features via __FEATURES__ define (for DCE)', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__)
|
||||
expect(features.shared).toBe('app')
|
||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
||||
})
|
||||
|
||||
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
// dotted entry is folded by esbuild to a literal → enables DCE of `__FEATURES__.x ? import() : []`
|
||||
expect(define['__FEATURES__.shared']).toBe('"app"')
|
||||
expect(define['__FEATURES__.app']).toBe('true')
|
||||
})
|
||||
|
||||
it('emits dotted defines at every nesting depth (so nested flags also DCE)', async () => {
|
||||
const cfg = await build(fixture('features/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
expect(define['__FEATURES__.billing']).toBe('false')
|
||||
expect(define['__FEATURES__.nested.enabled']).toBe('false') // deep leaf → foldable → DCE-able
|
||||
expect(define['__FEATURES__.nested.deep.on']).toBe('true')
|
||||
expect(define['__FEATURES__.nested']).toBe('{"enabled":false,"deep":{"on":true}}') // intermediate object too
|
||||
})
|
||||
|
||||
it('skips non-identifier feature keys in dotted defines (avoids INVALID_DEFINE_CONFIG crash)', async () => {
|
||||
const cfg = await build(fixture('features/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
// a dotted define with `kebab-flag` would crash the build; it is skipped here…
|
||||
expect(define['__FEATURES__.kebab-flag']).toBeUndefined()
|
||||
// …but still readable at runtime via the whole-object define.
|
||||
expect(JSON.parse(define.__FEATURES__)['kebab-flag']).toBe(true)
|
||||
})
|
||||
|
||||
it('runs lifecycle hooks: layers:resolved mutates features (before define), vite:config mutates config', async () => {
|
||||
const fn = (await buildViteConfig(fixture('stack/app'), {
|
||||
hooks: {
|
||||
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
||||
'vite:config': ctx => void (ctx.config.define = { ...ctx.config.define, INJECTED: '"yes"' }),
|
||||
},
|
||||
})) as UserConfigFnObject
|
||||
const cfg = (await fn(env)) as UserConfig
|
||||
const define = cfg.define as Record<string, string>
|
||||
expect(define['__FEATURES__.injected']).toBe('true') // layers:resolved ran before featureDefines
|
||||
expect(define.INJECTED).toBe('"yes"') // vite:config ran at the very end
|
||||
})
|
||||
|
||||
it('registers the layers resolver plugin', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
|
||||
expect(plugins.some(p => p?.name === 'vite-layers:resolve')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets ~~/@@ to the project rootDir and #layers/<name> per layer', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const alias = (cfg.resolve as { alias: Record<string, string> }).alias
|
||||
expect(alias['~~']).toBe(fixture('stack/app'))
|
||||
expect(alias['@@']).toBe(fixture('stack/app'))
|
||||
expect(alias['#layers/app']).toBe(fixture('stack/app'))
|
||||
expect(alias['#layers/base']).toBe(fixture('stack/base'))
|
||||
expect(alias['#layers/core']).toBe(fixture('stack/core'))
|
||||
})
|
||||
|
||||
it('defaults outDir to dist/<app>', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
expect((cfg.build as { outDir: string }).outDir).toBe('dist/app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupePlugins', () => {
|
||||
it('removes plugins sharing a name, keeping the later (higher-priority) instance in place', () => {
|
||||
const a: Plugin = { name: 'vue', apply: 'build' }
|
||||
const b: Plugin = { name: 'vue', apply: 'serve' }
|
||||
const other: Plugin = { name: 'other' }
|
||||
const out = dedupePlugins({ plugins: [a, other, b] }).plugins as Plugin[]
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[0]).toBe(b) // position of first 'vue', value of later one
|
||||
expect(out[1]).toBe(other)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { publicLayersPlugin } from '../src/public'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
|
||||
|
||||
function runGenerateBundle(p: { generateBundle?: unknown }): Record<string, string> {
|
||||
const emitted: Record<string, string> = {}
|
||||
const ctx = {
|
||||
emitFile: ({ fileName, source }: { fileName: string; source: Buffer | string }) => {
|
||||
emitted[fileName] = source.toString()
|
||||
},
|
||||
}
|
||||
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false)
|
||||
return emitted
|
||||
}
|
||||
|
||||
describe('publicLayersPlugin', () => {
|
||||
const high = fixture('public/high/public')
|
||||
const low = fixture('public/low/public')
|
||||
|
||||
it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
|
||||
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
|
||||
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
||||
const emitted = runGenerateBundle(publicLayersPlugin([high, low]))
|
||||
expect(emitted['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
||||
expect(emitted['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
|
||||
expect(emitted['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { layersResolver } from '../src/resolve'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
|
||||
|
||||
// roots ordered high→low priority: brand overrides base.
|
||||
const roots = [fixture('brand/src'), fixture('base/src')]
|
||||
const plugin = layersResolver({ roots })
|
||||
const resolveId = (id: string, importer?: string): string | null =>
|
||||
(plugin.resolveId as (id: string, importer?: string) => string | null)(id, importer)
|
||||
|
||||
describe('layersResolver', () => {
|
||||
it('ignores non-layered ids', () => {
|
||||
expect(resolveId('vue')).toBeNull()
|
||||
expect(resolveId('./relative')).toBeNull()
|
||||
expect(resolveId('#layers/base/x')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves @/ to the highest-priority layer that has the file', () => {
|
||||
expect(resolveId('@/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
|
||||
})
|
||||
|
||||
it('falls through to a lower layer when the higher one lacks the file', () => {
|
||||
expect(resolveId('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||
})
|
||||
|
||||
it('supports the ~/ prefix identically', () => {
|
||||
expect(resolveId('~/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
|
||||
})
|
||||
|
||||
it('probes <path>/index<ext> when no direct file exists', () => {
|
||||
expect(resolveId('@/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts'))
|
||||
})
|
||||
|
||||
it('self-skips: an override importing itself reaches the base layer (super())', () => {
|
||||
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||
const baseHeader = fixture('base/src/components/Header.vue')
|
||||
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('returns null when nothing matches across layers', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves query suffixes (?inline / ?raw / ?vue&type=…)', () => {
|
||||
expect(resolveId('@/components/Header.vue?vue&type=style&lang.css')).toBe(
|
||||
`${fixture('brand/src/components/Header.vue')}?vue&type=style&lang.css`,
|
||||
)
|
||||
})
|
||||
|
||||
it('honors custom prefixes and extensions', () => {
|
||||
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
|
||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
||||
expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only
|
||||
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
||||
})
|
||||
|
||||
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
||||
const p = layersResolver({ roots })
|
||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
||||
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
|
||||
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLayerHooks } from '../src/hooks'
|
||||
import { featuresDts, generateTsConfig } from '../src/tsconfig'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
describe('generateTsConfig', () => {
|
||||
it('maps @/* and ~/* to every layer srcDir in priority order (first-match = runtime resolver)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
// genDir is <app>/.vite-layers, so each src is one level up + the layer path
|
||||
expect(paths['@/*']).toEqual([
|
||||
'../src/*', // stack/app/src
|
||||
'../../base/src/*', // stack/base/src
|
||||
'../../core/src/*', // stack/core/src
|
||||
])
|
||||
expect(paths['~/*']).toEqual(paths['@/*'])
|
||||
})
|
||||
|
||||
it('maps ~~/@@ to the project root (bare + wildcard)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(paths['~~']).toEqual(['..'])
|
||||
expect(paths['~~/*']).toEqual(['../*'])
|
||||
expect(paths['@@']).toEqual(paths['~~'])
|
||||
})
|
||||
|
||||
it('emits #layers/<name>/* per layer rootDir', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(paths['#layers/app/*']).toEqual(['../*'])
|
||||
expect(paths['#layers/base/*']).toEqual(['../../base/*'])
|
||||
expect(paths['#layers/core/*']).toEqual(['../../core/*'])
|
||||
})
|
||||
|
||||
it('includes every layer srcDir glob', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
expect(tsconfig.include).toEqual(
|
||||
expect.arrayContaining(['../src/**/*', '../../base/src/**/*', '../../core/src/**/*']),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets framework-neutral defaults with no Vue/JSX specifics', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const co = tsconfig.compilerOptions!
|
||||
expect(co.moduleResolution).toBe('Bundler')
|
||||
expect(co.strict).toBe(true)
|
||||
expect(co).not.toHaveProperty('baseUrl') // deprecated in TS 6; paths resolve relative to the file
|
||||
expect(co).not.toHaveProperty('jsx')
|
||||
expect(co).not.toHaveProperty('jsxImportSource')
|
||||
})
|
||||
|
||||
it('merges per-layer `tsConfig` from app.config.ts across the stack (like Nuxt typescript.tsConfig)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('tsconfig-cfg/app'))
|
||||
const co = tsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.strict).toBe(false) // app layer overrides the default `true`
|
||||
expect(co.lib).toContain('ESNext') // from the app layer
|
||||
expect(co.types).toContain('node') // inherited from the base layer
|
||||
expect(co.moduleResolution).toBe('Bundler') // untouched default
|
||||
expect((co.paths as Record<string, string[]>)['@/*']).toBeDefined()
|
||||
})
|
||||
|
||||
it('opts.tsConfig wins over per-layer tsConfig and defaults, but never the generated paths', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'), {
|
||||
tsConfig: { compilerOptions: { strict: false, jsx: 'preserve', paths: { evil: ['/hax'] } } },
|
||||
})
|
||||
const co = tsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.strict).toBe(false) // user wins over default
|
||||
expect(co.jsx).toBe('preserve') // user can add options
|
||||
const paths = co.paths as Record<string, string[]>
|
||||
expect(paths.evil).toBeUndefined() // generated paths are authoritative
|
||||
expect(paths['@/*']).toBeDefined()
|
||||
})
|
||||
|
||||
it('generates a separate node tsconfig for config files (node-side, no DOM, no paths)', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.nodeFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/tsconfig\.node\.json$/)
|
||||
const co = r.nodeTsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.lib).toEqual(['ESNext']) // no DOM
|
||||
expect(co.paths).toEqual({}) // config files don't use @/
|
||||
expect(co.noEmit).toBe(true)
|
||||
// includes app.config / vite.config of each layer (app + base + core)
|
||||
expect(r.nodeTsconfig.include).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/app\.config\.\*$/),
|
||||
expect.stringMatching(/vite\.config\.\*$/),
|
||||
]),
|
||||
)
|
||||
// ...and the app config no longer pulls in config files
|
||||
expect((r.tsconfig.include ?? []).some(p => p.includes('app.config'))).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a tsconfig:generate hook mutate the node tsconfig', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
hooks.hook('tsconfig:generate', ctx => void (ctx.nodeTsconfig.compilerOptions!.removeComments = true))
|
||||
const r = await generateTsConfig(fixture('stack/app'), { hooks })
|
||||
expect((r.nodeTsconfig.compilerOptions as Record<string, unknown>).removeComments).toBe(true)
|
||||
})
|
||||
|
||||
it('includes ./features.d.ts and returns its generated content + path', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.tsconfig.include).toContain('./features.d.ts')
|
||||
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
|
||||
expect(r.dts).toContain('const __FEATURES__:')
|
||||
})
|
||||
|
||||
it('reuses a provided stack instead of resolving again (O2)', async () => {
|
||||
const stack = {
|
||||
merged: { features: { onlyInFake: true } },
|
||||
layers: [
|
||||
{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'FAKELAYER', config: {} },
|
||||
],
|
||||
}
|
||||
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
||||
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
|
||||
expect(r.dts).toContain('onlyInFake: boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('featuresDts', () => {
|
||||
it('renders a typed __FEATURES__ global (nested, primitives, quoted non-identifier keys)', () => {
|
||||
const dts = featuresDts({ billing: true, nested: { enabled: false }, 'kebab-flag': true, count: 2 })
|
||||
expect(dts).toContain('declare global')
|
||||
expect(dts).toContain('const __FEATURES__:')
|
||||
expect(dts).toContain('billing: boolean')
|
||||
expect(dts).toContain('nested: { enabled: boolean }')
|
||||
expect(dts).toContain('"kebab-flag": boolean')
|
||||
expect(dts).toContain('count: number')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user