feat: add support for #super imports in vite-layers
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Plugin } from 'vite'
|
||||
import { createLayeredResolution, layersResolver } from '../src/resolve'
|
||||
|
||||
@@ -70,6 +72,55 @@ describe('layersResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('#super — the explicit super() specifier', () => {
|
||||
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||
const baseHeader = fixture('base/src/components/Header.vue')
|
||||
|
||||
it('bare #super re-resolves the importer’s own path from the next-lower layer', () => {
|
||||
expect(resolveId('#super', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('#super/<path> resolves any path from strictly below the importer’s layer', () => {
|
||||
// the importer is NOT an override of Header — the implicit self-skip can't express this
|
||||
expect(resolveId('#super/components/Header.vue', fixture('brand/src/main.ts'))).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('never resolves to the importer’s own layer (strictly below, not first match)', () => {
|
||||
expect(resolveId('#super/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('returns null from the lowest layer / for a path absent below', () => {
|
||||
expect(resolveId('#super', baseHeader)).toBeNull()
|
||||
// Footer.vue exists only in base — from a brand importer it resolves DOWN to base's copy…
|
||||
expect(resolveId('#super/components/Footer.vue', brandHeader)).toBe(fixture('base/src/components/Footer.vue'))
|
||||
// …but from base itself there is nothing beneath.
|
||||
expect(resolveId('#super/components/Footer.vue', fixture('base/src/components/Footer.vue'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the importer is outside the layer stack (or absent)', () => {
|
||||
expect(resolveId('#super')).toBeNull()
|
||||
expect(resolveId('#super/components/Header.vue', '/somewhere/else/file.ts')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves query suffixes on both forms', () => {
|
||||
expect(resolveId('#super?raw', brandHeader)).toBe(`${baseHeader}?raw`)
|
||||
expect(resolveId('#super/components/Header.vue?vue&type=style', brandHeader)).toBe(`${baseHeader}?vue&type=style`)
|
||||
})
|
||||
|
||||
it('probes extensions/index like any layered id', () => {
|
||||
expect(resolveId('#super/widgets/Card', brandHeader)).toBe(fixture('base/src/widgets/Card/index.ts'))
|
||||
})
|
||||
|
||||
it('composes through a deep (3-layer) chain, one step down per layer', () => {
|
||||
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
|
||||
const dp = layersResolver({ roots: deepRoots })
|
||||
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
|
||||
expect(callResolveId(dp, '#super', W('top'))).toBe(W('mid'))
|
||||
expect(callResolveId(dp, '#super', W('mid'))).toBe(W('base'))
|
||||
expect(callResolveId(dp, '#super', W('base'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when nothing matches across layers', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
@@ -87,6 +138,15 @@ describe('layersResolver', () => {
|
||||
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
||||
})
|
||||
|
||||
it('the hook filter matches #super ids but not lookalikes', () => {
|
||||
const h = plugin.resolveId as { filter: { id: RegExp } }
|
||||
expect(h.filter.id.test('#super')).toBe(true)
|
||||
expect(h.filter.id.test('#super/components/Header.vue')).toBe(true)
|
||||
expect(h.filter.id.test('#super?raw')).toBe(true)
|
||||
expect(h.filter.id.test('#superstition')).toBe(false)
|
||||
expect(h.filter.id.test('@/components/Header.vue')).toBe(true)
|
||||
})
|
||||
|
||||
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
||||
const p = layersResolver({ roots })
|
||||
const rid = (id: string) => callResolveId(p, id)
|
||||
@@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => {
|
||||
expect(r.parse('#layers/base/x')).toBeNull()
|
||||
})
|
||||
|
||||
it('parse() accepts #super/ as a pseudo-prefix (so devtools can show its candidate stack)', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.parse('#super/components/Header.vue?raw')).toEqual({
|
||||
prefix: '#super/',
|
||||
sub: 'components/Header.vue',
|
||||
query: '?raw',
|
||||
})
|
||||
})
|
||||
|
||||
it('candidates() lists every matching file across layers, high→low', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.candidates('components/Header.vue')).toEqual([
|
||||
@@ -151,6 +220,78 @@ describe('createLayeredResolution (introspection core)', () => {
|
||||
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
|
||||
})
|
||||
|
||||
describe('targeted invalidation (invalidateFile / invalidateDir)', () => {
|
||||
// Real temp layers — invalidation is about reacting to FS changes, fixtures can't change.
|
||||
let tmp: string
|
||||
const setup = () => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'vite-layers-inv-'))
|
||||
const brandSrc = join(tmp, 'brand/src')
|
||||
const baseSrc = join(tmp, 'base/src')
|
||||
mkdirSync(join(baseSrc, 'components'), { recursive: true })
|
||||
mkdirSync(join(brandSrc, 'components'), { recursive: true })
|
||||
writeFileSync(join(baseSrc, 'components/Button.ts'), 'export default 1')
|
||||
return { brandSrc, baseSrc, r: createLayeredResolution({ roots: [brandSrc, baseSrc] }) }
|
||||
}
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }))
|
||||
|
||||
it('a new file in a higher layer changes the winner after invalidateFile', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) // cached
|
||||
const override = join(brandSrc, 'components/Button.ts')
|
||||
writeFileSync(override, 'export default 2')
|
||||
// control: the stale cache still serves the old winner…
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
r.invalidateFile(override)
|
||||
// …and the targeted invalidation flips it (extension-probe sub `components/Button` was dropped)
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
|
||||
})
|
||||
|
||||
it('leaves unrelated cache entries warm and ignores files outside every root', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
writeFileSync(join(baseSrc, 'components/Other.ts'), 'export default 3')
|
||||
r.resolveId('@/components/Other')
|
||||
const before = r.candidates('components/Other')
|
||||
r.invalidateFile(join(brandSrc, 'components/Button.ts')) // different sub
|
||||
r.invalidateFile(join(tmp, 'elsewhere/file.ts')) // outside every root — no-op
|
||||
expect(r.candidates('components/Other')).toBe(before) // same array identity → still cached
|
||||
})
|
||||
|
||||
it('a deleted file falls back to the lower layer after invalidateFile', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
const override = join(brandSrc, 'components/Button.ts')
|
||||
writeFileSync(override, 'export default 2')
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
|
||||
unlinkSync(override)
|
||||
r.invalidateFile(override)
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
})
|
||||
|
||||
it('invalidateFile on an index file also drops the bare-dir and dir/index subs', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
mkdirSync(join(baseSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(join(baseSrc, 'widgets/Card/index.ts'), 'export default 1')
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(baseSrc, 'widgets/Card/index.ts')))
|
||||
const override = join(brandSrc, 'widgets/Card/index.ts')
|
||||
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(override, 'export default 2')
|
||||
r.invalidateFile(override)
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(override))
|
||||
})
|
||||
|
||||
it('invalidateDir drops everything under the dir including its own index sub', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(join(brandSrc, 'widgets/Card/index.ts'), 'export default 2')
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(brandSrc, 'widgets/Card/index.ts')))
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
rmSync(join(brandSrc, 'widgets'), { recursive: true })
|
||||
r.invalidateDir(join(brandSrc, 'widgets'))
|
||||
expect(r.resolveId('@/widgets/Card')).toBeNull() // dir gone, nothing below
|
||||
const warm = r.candidates('components/Button')
|
||||
expect(r.candidates('components/Button')).toBe(warm) // unrelated entry untouched
|
||||
})
|
||||
})
|
||||
|
||||
it('the plugin and a shared resolution stay in sync', () => {
|
||||
const shared = createLayeredResolution({ roots, record: 10 })
|
||||
const plugin = layersResolver(shared)
|
||||
|
||||
@@ -107,6 +107,32 @@ describe('generateTsConfig', () => {
|
||||
expect(r.dts).toContain(`declare module '#feature'`)
|
||||
})
|
||||
|
||||
it('maps #super/* to the layers BELOW the project layer (explicit super() is typed)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
// project layer (app) excluded — super() never resolves to the importer's own layer
|
||||
expect(paths['#super/*']).toEqual(['../../base/src/*', '../../core/src/*'])
|
||||
})
|
||||
|
||||
it('omits #super/* for a single-layer stack (paths forbids empty substitution arrays)', async () => {
|
||||
const stack = {
|
||||
merged: {},
|
||||
layers: [{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'app', config: {} }],
|
||||
}
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
||||
expect(tsconfig.compilerOptions!.paths).not.toHaveProperty('#super/*')
|
||||
})
|
||||
|
||||
it('emits an ambient super.d.ts so a bare `import "#super"` type-checks', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.tsconfig.include).toContain('./super.d.ts')
|
||||
expect(r.superDtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/super\.d\.ts$/)
|
||||
expect(r.superDts).toContain(`declare module '#super' {}`)
|
||||
// must stay a SCRIPT file: a top-level import would turn `declare module` into an augmentation
|
||||
// of an unresolvable specifier (TS2664)
|
||||
expect(r.superDts).not.toMatch(/^\s*(import|export)\b/m)
|
||||
})
|
||||
|
||||
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
|
||||
Reference in New Issue
Block a user