feat: add feature plugin tests and validation for feature flags
This commit is contained in:
@@ -2,7 +2,7 @@ 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'
|
||||
import { configWatchPlugin } from '../src/dev'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
@@ -39,35 +39,13 @@ describe('configWatchPlugin', () => {
|
||||
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()
|
||||
it('restarts when a config is newly added to a layer that had none', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
// app.config.js does not exist at startup, but it is a candidate path → `add` must restart.
|
||||
watcher.emit('add', resolve(fixture('stack/app'), 'app.config.js'))
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { JsonRenderElement, JsonRenderSpec, ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
|
||||
import { resolveLayerStack } from '../src/config'
|
||||
import { inheritanceTreeText, layersDevtoolsPlugin, type LayersDevtoolsData } from '../src/devtools'
|
||||
import { createLayeredResolution } from '../src/resolve'
|
||||
import type { LayerStack } from '../src/types'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'devtools', p))
|
||||
const env = { command: 'serve', mode: 'development', isSsrBuild: false, isPreview: false } as const
|
||||
|
||||
/**
|
||||
* Validate a json-render spec is renderable: the root exists, every referenced child id exists, and
|
||||
* every action wired to a button is one our plugin actually registers. Catches the classic broken
|
||||
* spec (a dangling child id) that would render as a blank panel.
|
||||
*/
|
||||
function assertValidSpec(spec: JsonRenderSpec, registeredActions: Set<string>) {
|
||||
expect(spec.elements[spec.root], `root "${spec.root}" missing`).toBeTruthy()
|
||||
const visit = (el: JsonRenderElement) => {
|
||||
for (const childId of el.children ?? []) {
|
||||
expect(spec.elements[childId], `dangling child id "${childId}"`).toBeTruthy()
|
||||
}
|
||||
const press = (el.on as { press?: { action?: string } } | undefined)?.press
|
||||
if (press?.action) expect(registeredActions.has(press.action), `unknown action "${press.action}"`).toBe(true)
|
||||
}
|
||||
for (const el of Object.values(spec.elements)) visit(el)
|
||||
}
|
||||
|
||||
interface RendererHandle {
|
||||
spec: JsonRenderSpec
|
||||
updateSpec: (s: JsonRenderSpec) => void
|
||||
updateState: (s: Record<string, unknown>) => void
|
||||
_stateKey: string
|
||||
}
|
||||
|
||||
/** A minimal stand-in for the kit's node context — records what the plugin registers. */
|
||||
function makeCtx() {
|
||||
const docks: Array<{ entry: Record<string, unknown>; patches: Array<Record<string, unknown>> }> = []
|
||||
const rpc = new Map<string, (params?: Record<string, unknown>) => unknown>()
|
||||
const commands: Array<Record<string, unknown>> = []
|
||||
const messages: Array<Record<string, unknown>> = []
|
||||
const renderers: RendererHandle[] = []
|
||||
|
||||
const ctx = {
|
||||
createJsonRenderer(spec: JsonRenderSpec): RendererHandle {
|
||||
const handle: RendererHandle = {
|
||||
spec,
|
||||
_stateKey: `state:${renderers.length}`,
|
||||
updateSpec(s) {
|
||||
handle.spec = s
|
||||
},
|
||||
updateState() {},
|
||||
}
|
||||
renderers.push(handle)
|
||||
return handle
|
||||
},
|
||||
docks: {
|
||||
register(entry: Record<string, unknown>) {
|
||||
const rec = { entry, patches: [] as Array<Record<string, unknown>> }
|
||||
docks.push(rec)
|
||||
return { update: (patch: Record<string, unknown>) => rec.patches.push(patch) }
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
register(def: { name: string; setup: () => { handler: (p?: Record<string, unknown>) => unknown } }) {
|
||||
rpc.set(def.name, def.setup().handler)
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
register(cmd: Record<string, unknown>) {
|
||||
commands.push(cmd)
|
||||
return { id: cmd.id, update() {}, unregister() {} }
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
add(input: Record<string, unknown>) {
|
||||
messages.push(input)
|
||||
return Promise.resolve({ id: String(input.id ?? ''), entry: input, update: async () => undefined, dismiss: async () => {} })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return { ctx: ctx as unknown as ViteDevToolsNodeContext, docks, rpc, commands, messages, renderers }
|
||||
}
|
||||
|
||||
describe('layersDevtoolsPlugin', () => {
|
||||
let stack: LayerStack
|
||||
let data: LayersDevtoolsData
|
||||
|
||||
beforeAll(async () => {
|
||||
stack = await resolveLayerStack(fixture('app'))
|
||||
data = {
|
||||
appDir: fixture('app'),
|
||||
env,
|
||||
stack,
|
||||
resolution: createLayeredResolution({ roots: stack.layers.map(l => l.srcDir), record: 50 }),
|
||||
tsconfig: {},
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a Vite plugin carrying a devtools.setup hook', () => {
|
||||
const plugin = layersDevtoolsPlugin(data)
|
||||
expect(plugin.name).toBe('vite-layers:devtools')
|
||||
expect(typeof plugin.devtools?.setup).toBe('function')
|
||||
})
|
||||
|
||||
it('resolves the expected two-layer fixture stack (app over base)', () => {
|
||||
expect(stack.layers.map(l => l.name)).toEqual(['app', 'base'])
|
||||
expect(stack.merged.features).toMatchObject({ billing: false, shared: 'base' })
|
||||
})
|
||||
|
||||
it('registers a group + four json-render panels, all with valid specs', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
|
||||
const ids = m.docks.map(d => d.entry.id)
|
||||
expect(ids).toContain('vite-layers')
|
||||
expect(ids).toEqual(expect.arrayContaining(['vite-layers:layers', 'vite-layers:features', 'vite-layers:resolver', 'vite-layers:assets']))
|
||||
|
||||
const group = m.docks.find(d => d.entry.id === 'vite-layers')!.entry
|
||||
expect(group.type).toBe('group')
|
||||
|
||||
const panels = m.docks.filter(d => d.entry.type === 'json-render')
|
||||
expect(panels).toHaveLength(4)
|
||||
for (const p of panels) expect(p.entry.groupId).toBe('vite-layers')
|
||||
|
||||
const actions = new Set(m.rpc.keys())
|
||||
for (const h of m.renderers) assertValidSpec(h.spec, actions)
|
||||
})
|
||||
|
||||
it('registers the refresh / resolve / clear-log actions and a refresh command', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
expect([...m.rpc.keys()]).toEqual(
|
||||
expect.arrayContaining(['vite-layers:refresh', 'vite-layers:resolve', 'vite-layers:clear-log']),
|
||||
)
|
||||
expect(m.commands.some(c => c.id === 'vite-layers:refresh')).toBe(true)
|
||||
})
|
||||
|
||||
it('badges the Features panel with the disabled-flag count', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
const featuresDock = m.docks.find(d => d.entry.id === 'vite-layers:features')!
|
||||
// `billing` is the only leaf flag disabled in the merged stack.
|
||||
expect(featuresDock.patches.some(p => p.badge === '1')).toBe(true)
|
||||
})
|
||||
|
||||
it('emits an init message summarizing the stack', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
expect(m.messages).toHaveLength(1)
|
||||
expect(m.messages[0]).toMatchObject({ level: 'info', category: 'vite-layers' })
|
||||
expect(String(m.messages[0]!.message)).toContain('2 layers')
|
||||
})
|
||||
|
||||
it('resolve action computes the candidate stack and rebuilds the resolver panel', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
|
||||
const resolverDock = m.docks.find(d => d.entry.id === 'vite-layers:resolver')!
|
||||
const resolverUi = resolverDock.entry.ui as RendererHandle
|
||||
|
||||
await m.rpc.get('vite-layers:resolve')!({ id: '@/components/Header.vue' })
|
||||
|
||||
const json = JSON.stringify(resolverUi.spec)
|
||||
// app/Header.vue wins, base/Header.vue is shadowed — both candidate files appear.
|
||||
expect(json).toContain(toPosix(resolve(fixture('app'), 'src/components/Header.vue')))
|
||||
expect(json).toContain(toPosix(resolve(fixture('base'), 'src/components/Header.vue')))
|
||||
expect(json).toContain('winner')
|
||||
expect(json).toContain('shadowed')
|
||||
|
||||
const actions = new Set(m.rpc.keys())
|
||||
assertValidSpec(resolverUi.spec, actions)
|
||||
})
|
||||
|
||||
it('reports a friendly error for a non-layered id', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
const resolverUi = m.docks.find(d => d.entry.id === 'vite-layers:resolver')!.entry.ui as RendererHandle
|
||||
await m.rpc.get('vite-layers:resolve')!({ id: 'vue' })
|
||||
expect(JSON.stringify(resolverUi.spec)).toContain('Not a layered id')
|
||||
})
|
||||
|
||||
it('works with tsconfig autogen disabled', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin({ ...data, tsconfig: false }).devtools!.setup!(m.ctx)
|
||||
const assetsUi = m.docks.find(d => d.entry.id === 'vite-layers:assets')!.entry.ui as RendererHandle
|
||||
expect(JSON.stringify(assetsUi.spec)).toContain('disabled')
|
||||
})
|
||||
|
||||
it('renders the inheritance tree into the Layers panel', async () => {
|
||||
const m = makeCtx()
|
||||
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
|
||||
const layersUi = m.docks.find(d => d.entry.id === 'vite-layers:layers')!.entry.ui as RendererHandle
|
||||
const json = JSON.stringify(layersUi.spec)
|
||||
expect(json).toContain('Inheritance (extends graph)')
|
||||
expect(json).toContain('└── base')
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritanceTreeText', () => {
|
||||
let appStack: LayerStack
|
||||
beforeAll(async () => {
|
||||
appStack = await resolveLayerStack(fixture('app'))
|
||||
})
|
||||
|
||||
it('draws a simple chain (app extends base)', () => {
|
||||
const tree = inheritanceTreeText(appStack)
|
||||
const lines = tree.split('\n')
|
||||
expect(lines[0]).toMatch(/^app {2}#0 {3}\(project/)
|
||||
expect(lines[1]).toBe('└── base #1')
|
||||
})
|
||||
|
||||
it('draws a diamond once, marking the repeated node with ↑ above (no infinite recursion)', async () => {
|
||||
const stack = await resolveLayerStack(toPosix(resolve(here, 'fixtures', 'diamond', 'app')))
|
||||
const tree = inheritanceTreeText(stack)
|
||||
// app → b → d, and app → c → d (d is the diamond tip, reached twice)
|
||||
expect(tree).toContain('├── b')
|
||||
expect(tree).toContain('└── c')
|
||||
expect(tree).toContain('↑ above') // d's second occurrence is collapsed, not re-expanded
|
||||
// d is drawn exactly once in full + once as a back-reference
|
||||
expect(tree.match(/^.*── d {2}#\d/gm)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('marks an edge to a non-layer (npm/git) target as external', () => {
|
||||
const synthetic: LayerStack = {
|
||||
merged: {},
|
||||
layers: [{ name: 'app', rootDir: '/x/app', srcDir: '/x/app/src', config: {} }],
|
||||
edges: [{ from: '/x/app', to: '/x/node_modules/some-npm-layer', source: 'some-npm-layer' }],
|
||||
}
|
||||
expect(inheritanceTreeText(synthetic)).toContain('some-npm-layer (external)')
|
||||
})
|
||||
|
||||
it('lists layers not reached via the edge graph (auto-scan fallback)', () => {
|
||||
const synthetic: LayerStack = {
|
||||
merged: {},
|
||||
layers: [
|
||||
{ name: 'app', rootDir: '/x/app', srcDir: '/x/app/src', config: {} },
|
||||
{ name: 'scanned', rootDir: '/x/scanned', srcDir: '/x/scanned/src', config: {} },
|
||||
],
|
||||
edges: [], // nothing links to `scanned`
|
||||
}
|
||||
const tree = inheritanceTreeText(synthetic)
|
||||
expect(tree).toContain('not reached via extends')
|
||||
expect(tree).toContain('• scanned #1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Plugin } from 'vite'
|
||||
import { FEATURE_MODULE, featurePlugin, featuresDts, flattenFeatures } from '../src/features'
|
||||
|
||||
// Minimal TransformPluginContext stand-in: `this.error` throws (as it does for a real build failure).
|
||||
const ctx = {
|
||||
error(msg: string | { message: string }): never {
|
||||
throw new Error(typeof msg === 'string' ? msg : msg.message)
|
||||
},
|
||||
}
|
||||
|
||||
function transform(features: Record<string, unknown>, code: string, id = '/app/src/x.ts') {
|
||||
const t = featurePlugin(features).transform as Plugin['transform']
|
||||
const handler = (typeof t === 'function' ? t : t!.handler) as (
|
||||
this: unknown,
|
||||
code: string,
|
||||
id: string,
|
||||
) => { code: string; map?: unknown } | null
|
||||
return handler.call(ctx, code, id)
|
||||
}
|
||||
|
||||
describe('featurePlugin', () => {
|
||||
it("replaces feature('key') with the flag literal and removes the import", () => {
|
||||
const out = transform({ billing: false }, `import { feature } from '#feature'\nexport const r = feature('billing') ? 1 : 2\n`)
|
||||
expect(out).not.toBeNull()
|
||||
expect(out!.code).toContain('export const r = false ? 1 : 2')
|
||||
expect(out!.code).not.toContain("from '#feature'")
|
||||
expect((out!.map as { mappings?: string }).mappings).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resolves nested dotted keys', () => {
|
||||
const out = transform({ nested: { deep: { on: true } } }, `import { feature } from '#feature'\nconst a = feature('nested.deep.on')\n`)
|
||||
expect(out!.code).toContain('const a = true')
|
||||
})
|
||||
|
||||
it('substitutes an object-valued key as a parenthesized literal (valid in any position)', () => {
|
||||
const out = transform({ nested: { on: true } }, `import { feature } from '#feature'\nconst a = feature('nested')\n`)
|
||||
expect(out!.code).toContain('const a = ({"on":true})')
|
||||
})
|
||||
|
||||
it('honours an import alias (import { feature as f })', () => {
|
||||
const out = transform({ billing: true }, `import { feature as f } from '#feature'\nconst a = f('billing')\n`)
|
||||
expect(out!.code).toContain('const a = true')
|
||||
})
|
||||
|
||||
it('also accepts the vite-layers/feature specifier', () => {
|
||||
const out = transform({ billing: true }, `import { feature } from 'vite-layers/feature'\nconst a = feature('billing')\n`)
|
||||
expect(out!.code).toContain('const a = true')
|
||||
})
|
||||
|
||||
it('parses TSX and gates JSX expressions', () => {
|
||||
const out = transform({ billing: false }, `import { feature } from '#feature'\nexport const n = feature('billing') && 1\n`, '/app/src/x.tsx')
|
||||
expect(out!.code).toContain('export const n = false && 1')
|
||||
})
|
||||
|
||||
it('fails the build on a dynamic (non-literal) key', () => {
|
||||
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nconst k = 'billing'\nexport const a = feature(k)\n`))
|
||||
.toThrow(/single string-literal key/)
|
||||
})
|
||||
|
||||
it('fails the build when the macro is aliased / passed as a value', () => {
|
||||
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nexport const g = feature\n`))
|
||||
.toThrow(/compile-time macro/)
|
||||
})
|
||||
|
||||
it('fails the build on an unknown flag', () => {
|
||||
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nexport const a = feature('bling')\n`))
|
||||
.toThrow(/unknown feature flag 'bling'/)
|
||||
})
|
||||
|
||||
it('fails the build on re-exporting the macro', () => {
|
||||
expect(() => transform({ billing: true }, `export { feature } from '#feature'\n`))
|
||||
.toThrow(/re-exporting the `feature` macro/)
|
||||
})
|
||||
|
||||
it('fails the build on a default import of the macro', () => {
|
||||
expect(() => transform({ billing: true }, `import feature from '#feature'\nconst a = feature('billing')\n`))
|
||||
.toThrow(/named \{ feature \}/)
|
||||
})
|
||||
|
||||
it('fails the build on a namespace import of the macro', () => {
|
||||
expect(() => transform({ billing: true }, `import * as F from '#feature'\nconst a = F.feature('billing')\n`))
|
||||
.toThrow(/named \{ feature \}/)
|
||||
})
|
||||
|
||||
it('leaves modules without the macro import untouched (even if the token appears in a string)', () => {
|
||||
expect(transform({ billing: true }, 'export const x = 1\n')).toBeNull()
|
||||
expect(transform({ billing: true }, `export const s = 'mentions #feature in a string'\n`)).toBeNull()
|
||||
})
|
||||
|
||||
it('fails loudly (never silently skips) when a module that imports the macro fails to parse', () => {
|
||||
// oxc reports errors without throwing and yields an empty body — which must NOT look like "no macro".
|
||||
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nconst x = @@@ broken(((`))
|
||||
.toThrow(/syntax error|could not parse/i)
|
||||
})
|
||||
|
||||
it('does not over-fail: a broken module that only mentions #feature in a string is left alone', () => {
|
||||
expect(transform({ billing: true }, `const s = 'see #feature'\nconst x = @@@ broken(((`)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips node_modules', () => {
|
||||
expect(transform({ billing: true }, `import { feature } from '#feature'\nconst a = feature('billing')\n`, '/x/node_modules/y.js')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an unrelated local named `feature` (param/const) untouched — no false positive', () => {
|
||||
const arrow = transform({ billing: true }, `import { feature } from '#feature'\nexport const a = feature('billing')\nexport const xs = [1].map(feature => feature + 1)\n`)
|
||||
expect(arrow!.code).toContain('export const a = true') // the real macro call still folds
|
||||
expect(arrow!.code).toContain('feature => feature + 1') // the shadowing param is left alone
|
||||
const local = transform({ billing: true }, `import { feature } from '#feature'\nexport function f(){ const feature = () => 1; return feature() }\nexport const a = feature('billing')\n`)
|
||||
expect(local!.code).toContain('const feature = () => 1; return feature()')
|
||||
expect(local!.code).toContain('export const a = true')
|
||||
})
|
||||
|
||||
it('allows type-position references (typeof feature) and ignores `import type`', () => {
|
||||
const out = transform({ billing: true }, `import { feature } from '#feature'\ntype T = typeof feature\nexport const a = feature('billing')\n`)
|
||||
expect(out!.code).toContain('export const a = true') // the value call folds; the type query is skipped
|
||||
// a pure `import type { feature }` is erased — nothing to compile
|
||||
expect(transform({ billing: true }, `import type { feature } from '#feature'\nexport type T = typeof feature\n`)).toBeNull()
|
||||
})
|
||||
|
||||
it('fails the build (never silently mis-substitutes) on a malformed template-literal key', () => {
|
||||
// An untagged template with a bad escape is a parse error → caught loudly; if it ever parsed
|
||||
// with a null cooked value, stringKey routes it to the string-literal-key error instead.
|
||||
expect(() => transform({ billing: true }, 'import { feature } from \'#feature\'\nconst a = feature(`\\unicode`)\n'))
|
||||
.toThrow(/vite-layers/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature value validation', () => {
|
||||
it('rejects unsupported value types with a clear error (plugin + dts)', () => {
|
||||
for (const features of [{ a: 1n }, { a: () => 1 }, { a: Number.NaN }, { a: Number.POSITIVE_INFINITY }, { a: Symbol('x') }, { a: new Date() }]) {
|
||||
expect(() => featurePlugin(features as Record<string, unknown>)).toThrow(/unsupported value type/)
|
||||
expect(() => featuresDts(features as Record<string, unknown>)).toThrow(/unsupported value type/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a dotted key colliding with a nested path', () => {
|
||||
expect(() => featurePlugin({ 'a.b': 1, a: { b: 2 } })).toThrow(/defined twice/)
|
||||
expect(() => featuresDts({ 'a.b': 1, a: { b: 2 } })).toThrow(/defined twice/)
|
||||
})
|
||||
|
||||
it('accepts JSON-like values (bool, finite number, string, null, plain object, array)', () => {
|
||||
expect(() => featurePlugin({ a: true, b: 1.5, c: 'x', d: null, e: { f: 1 }, g: ['x', 2] })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('featuresDts', () => {
|
||||
it('augments LayerFeatures on #feature with literal types and dotted keys', () => {
|
||||
const dts = featuresDts({ billing: false, nested: { enabled: true }, 'kebab-flag': true, count: 2 })
|
||||
expect(dts).toContain(`import '${FEATURE_MODULE}'`)
|
||||
expect(dts).toContain(`declare module '${FEATURE_MODULE}'`)
|
||||
expect(dts).toContain('interface LayerFeatures')
|
||||
expect(dts).toContain('billing: false') // literal, not widened `boolean`
|
||||
expect(dts).toContain('nested: { enabled: true }')
|
||||
expect(dts).toContain('"nested.enabled": true') // dotted leaf key for direct DCE access
|
||||
expect(dts).toContain('"kebab-flag": true') // non-identifier keys are now fully supported
|
||||
expect(dts).toContain('count: 2')
|
||||
})
|
||||
|
||||
it('renders an empty augmentation when there are no features', () => {
|
||||
expect(featuresDts({})).toContain('interface LayerFeatures {\n }')
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenFeatures', () => {
|
||||
it('emits both intermediate and leaf dotted paths in order', () => {
|
||||
expect(flattenFeatures({ a: { b: 1 }, c: true })).toEqual([
|
||||
['a', { b: 1 }],
|
||||
['a.b', 1],
|
||||
['c', true],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
extends: ['../base'],
|
||||
features: { billing: false }, // overrides base — disabled leaf → DCE
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg><!-- app logo --></svg>
|
||||
|
After Width: | Height: | Size: 29 B |
@@ -0,0 +1 @@
|
||||
<template><header>app header</header></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
name: 'base',
|
||||
features: { billing: true, shared: 'base', nested: { on: true } },
|
||||
hooks: {
|
||||
'layers:resolved': () => {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg><!-- base favicon --></svg>
|
||||
|
After Width: | Height: | Size: 33 B |
@@ -0,0 +1 @@
|
||||
<svg><!-- base logo --></svg>
|
||||
|
After Width: | Height: | Size: 30 B |
@@ -0,0 +1 @@
|
||||
<template><footer>base footer</footer></template>
|
||||
@@ -0,0 +1 @@
|
||||
<template><header>base header</header></template>
|
||||
@@ -0,0 +1 @@
|
||||
<template><div>billing</div></template>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- deep/base --><template><span>base</span></template>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- deep/mid --><template><span>mid</span></template>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- deep/top --><template><span>top</span></template>
|
||||
@@ -13,41 +13,37 @@ async function build(appDir: string): Promise<UserConfig> {
|
||||
return (await fn(env)) as UserConfig
|
||||
}
|
||||
|
||||
const featCtx = {
|
||||
error(m: string | { message: string }): never {
|
||||
throw new Error(typeof m === 'string' ? m : m.message)
|
||||
},
|
||||
}
|
||||
const runTransform = (plugin: Plugin, code: string, id = '/app/src/x.ts') => {
|
||||
const t = plugin.transform as Plugin['transform']
|
||||
const handler = (typeof t === 'function' ? t : t!.handler) as (
|
||||
this: unknown,
|
||||
c: string,
|
||||
i: string,
|
||||
) => { code?: unknown } | null
|
||||
return handler.call(featCtx, code, id)
|
||||
}
|
||||
|
||||
describe('buildViteConfig', () => {
|
||||
it('exposes merged features via __FEATURES__ define (for DCE)', async () => {
|
||||
it('registers the feature macro plugin and aliases #feature to the macro entry', 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 })
|
||||
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
|
||||
expect(plugins.some(p => p?.name === 'vite-layers:features')).toBe(true)
|
||||
const alias = (cfg.resolve as { alias: Record<string, string> }).alias
|
||||
expect(alias['#feature']).toMatch(/\/src\/feature\.ts$/)
|
||||
})
|
||||
|
||||
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => {
|
||||
it('emits no __FEATURES__ define (flags compile via the feature() macro, not define)', 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')
|
||||
const define = (cfg.define ?? {}) as Record<string, string>
|
||||
expect(Object.keys(define).some(k => k.startsWith('__FEATURES__'))).toBe(false)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('compiles feature() against the merged flags; layers:resolved mutates them first, vite:config runs last', async () => {
|
||||
const fn = (await buildViteConfig(fixture('stack/app'), {
|
||||
hooks: {
|
||||
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
||||
@@ -55,9 +51,10 @@ describe('buildViteConfig', () => {
|
||||
},
|
||||
})) 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
|
||||
const feat = (cfg.plugins as Plugin[]).flat(Infinity as 1).find(p => (p as Plugin)?.name === 'vite-layers:features') as Plugin
|
||||
const out = runTransform(feat, `import { feature } from '#feature'\nexport const a = feature('injected')\n`)
|
||||
expect(String(out?.code)).toContain('export const a = true') // layers:resolved ran before the macro read features
|
||||
expect((cfg.define as Record<string, string>).INJECTED).toBe('"yes"') // vite:config ran at the very end
|
||||
})
|
||||
|
||||
it('registers the layers resolver plugin', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { publicLayersPlugin } from '../src/public'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -8,30 +10,65 @@ 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()
|
||||
},
|
||||
type ResolvedishConfig = { root: string; build: { outDir: string; copyPublicDir?: boolean } }
|
||||
|
||||
/** List every file written under `dir` as `posixRelativePath → contents`. */
|
||||
function snapshot(dir: string): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
const walk = (d: string) => {
|
||||
for (const name of readdirSync(d, { withFileTypes: true })) {
|
||||
const abs = join(d, name.name)
|
||||
if (name.isDirectory()) walk(abs)
|
||||
else out[resolve(abs).slice(resolve(dir).length + 1).replace(/\\/g, '/')] = readFileSync(abs, 'utf8')
|
||||
}
|
||||
}
|
||||
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false)
|
||||
return emitted
|
||||
walk(dir)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drive the build-time hooks (configResolved → writeBundle) and return what landed on disk. */
|
||||
function runBuild(
|
||||
p: { configResolved?: unknown; writeBundle?: unknown },
|
||||
outDir: string,
|
||||
{ copyPublicDir, writeDir }: { copyPublicDir?: boolean; writeDir?: string } = {},
|
||||
): Record<string, string> {
|
||||
const cfg: ResolvedishConfig = { root: '/', build: { outDir, copyPublicDir } }
|
||||
;(p.configResolved as (c: ResolvedishConfig) => void)(cfg)
|
||||
;(p.writeBundle as (this: unknown, o: { dir?: string }) => void).call({}, { dir: writeDir ?? outDir })
|
||||
return snapshot(outDir)
|
||||
}
|
||||
|
||||
describe('publicLayersPlugin', () => {
|
||||
const high = fixture('public/high/public')
|
||||
const low = fixture('public/low/public')
|
||||
let outDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
outDir = mkdtempSync(join(tmpdir(), 'vite-layers-public-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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
|
||||
it('copies assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
||||
const written = runBuild(publicLayersPlugin([high, low]), outDir)
|
||||
expect(written['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
||||
expect(written['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
|
||||
expect(written['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
|
||||
})
|
||||
|
||||
it('skips the copy when Vite opts out (copyPublicDir: false)', () => {
|
||||
const written = runBuild(publicLayersPlugin([high, low]), outDir, { copyPublicDir: false })
|
||||
expect(written).toEqual({})
|
||||
})
|
||||
|
||||
it('only copies for the output targeting the main outDir', () => {
|
||||
const written = runBuild(publicLayersPlugin([high, low]), outDir, { writeDir: join(outDir, 'server') })
|
||||
expect(written).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { layersResolver } from '../src/resolve'
|
||||
import type { Plugin } from 'vite'
|
||||
import { createLayeredResolution, 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))
|
||||
|
||||
// `resolveId` is now a filtered object hook (`{ filter, handler }`); call its handler.
|
||||
const callResolveId = (plugin: Plugin, id: string, importer?: string): string | null => {
|
||||
const h = plugin.resolveId
|
||||
const fn = (typeof h === 'function' ? h : h?.handler) as (id: string, importer?: string) => string | null
|
||||
return fn(id, importer)
|
||||
}
|
||||
|
||||
// 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)
|
||||
const resolveId = (id: string, importer?: string): string | null => callResolveId(plugin, id, importer)
|
||||
|
||||
describe('layersResolver', () => {
|
||||
it('ignores non-layered ids', () => {
|
||||
@@ -42,6 +49,27 @@ describe('layersResolver', () => {
|
||||
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
describe('super() through a deep (3-layer) extends chain', () => {
|
||||
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
|
||||
const dp = layersResolver({ roots: deepRoots })
|
||||
const drid = (id: string, importer?: string) => callResolveId(dp, id, importer)
|
||||
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
|
||||
|
||||
it('a normal import resolves to the highest layer', () => {
|
||||
expect(drid('@/components/Widget.vue')).toBe(W('top'))
|
||||
})
|
||||
|
||||
it('super() resolves to the NEXT-LOWER layer at every level (never upward)', () => {
|
||||
expect(drid('@/components/Widget.vue', W('top'))).toBe(W('mid'))
|
||||
// the regression guard: a shadowed middle layer must reach `base`, not jump back up to `top`
|
||||
expect(drid('@/components/Widget.vue', W('mid'))).toBe(W('base'))
|
||||
})
|
||||
|
||||
it('super() from the lowest layer resolves to null (nothing beneath it)', () => {
|
||||
expect(drid('@/components/Widget.vue', W('base'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when nothing matches across layers', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
@@ -54,15 +82,81 @@ describe('layersResolver', () => {
|
||||
|
||||
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)
|
||||
const rid = (id: string) => callResolveId(p, 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)
|
||||
const rid = (id: string) => callResolveId(p, id)
|
||||
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
|
||||
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLayeredResolution (introspection core)', () => {
|
||||
it('parse() splits prefix/sub/query and rejects non-layered ids', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.parse('@/components/Header.vue?raw')).toEqual({ prefix: '@/', sub: 'components/Header.vue', query: '?raw' })
|
||||
expect(r.parse('vue')).toBeNull()
|
||||
expect(r.parse('#layers/base/x')).toBeNull()
|
||||
})
|
||||
|
||||
it('candidates() lists every matching file across layers, high→low', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.candidates('components/Header.vue')).toEqual([
|
||||
fixture('brand/src/components/Header.vue'),
|
||||
fixture('base/src/components/Header.vue'),
|
||||
])
|
||||
expect(r.candidates('components/Footer.vue')).toEqual([fixture('base/src/components/Footer.vue')])
|
||||
expect(r.candidates('components/Missing.vue')).toEqual([])
|
||||
})
|
||||
|
||||
it('records resolutions only when enabled, newest-first, de-duplicated by id+importer', () => {
|
||||
const off = createLayeredResolution({ roots })
|
||||
off.resolveId('@/components/Header.vue')
|
||||
expect(off.records()).toEqual([]) // recording disabled by default
|
||||
|
||||
const r = createLayeredResolution({ roots, record: 10 })
|
||||
r.resolveId('@/components/Header.vue')
|
||||
r.resolveId('@/components/Footer.vue')
|
||||
r.resolveId('@/components/Header.vue') // repeat → updates the existing entry, no duplicate
|
||||
const recs = r.records()
|
||||
expect(recs).toHaveLength(2)
|
||||
expect(recs[0]!.id).toBe('@/components/Header.vue') // most-recent first
|
||||
expect(recs[0]!.candidates).toEqual([
|
||||
fixture('brand/src/components/Header.vue'),
|
||||
fixture('base/src/components/Header.vue'),
|
||||
])
|
||||
expect(recs[0]!.selfIndex).toBe(-1) // a normal (non-self) import
|
||||
|
||||
r.clearRecords()
|
||||
expect(r.records()).toEqual([])
|
||||
})
|
||||
|
||||
it('records a super() self-import with the importer position', () => {
|
||||
const r = createLayeredResolution({ roots, record: 10 })
|
||||
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||
expect(r.resolveId('@/components/Header.vue', brandHeader)).toBe(fixture('base/src/components/Header.vue'))
|
||||
expect(r.records()[0]!.selfIndex).toBe(0) // importer is the top candidate → super() skips to #1
|
||||
})
|
||||
|
||||
it('keeps the log bounded to the record size', () => {
|
||||
const r = createLayeredResolution({ roots, record: 2 })
|
||||
r.resolveId('@/components/Header.vue')
|
||||
r.resolveId('@/components/Footer.vue')
|
||||
r.resolveId('@/components/Missing.vue')
|
||||
expect(r.records()).toHaveLength(2) // oldest (Header) evicted
|
||||
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
|
||||
})
|
||||
|
||||
it('the plugin and a shared resolution stay in sync', () => {
|
||||
const shared = createLayeredResolution({ roots, record: 10 })
|
||||
const plugin = layersResolver(shared)
|
||||
callResolveId(plugin, '@/components/Header.vue')
|
||||
// the resolution the plugin wraps recorded the resolveId the plugin handled
|
||||
expect(shared.records()).toHaveLength(1)
|
||||
expect(shared.records()[0]!.resolved).toBe(fixture('brand/src/components/Header.vue'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { generateTsConfig } from '../src/tsconfig'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
@@ -104,7 +104,13 @@ describe('generateTsConfig', () => {
|
||||
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__:')
|
||||
expect(r.dts).toContain(`declare module '#feature'`)
|
||||
})
|
||||
|
||||
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[]>
|
||||
expect(paths['#feature']?.[0]).toMatch(/\/src\/feature$/)
|
||||
})
|
||||
|
||||
it('reuses a provided stack instead of resolving again (O2)', async () => {
|
||||
@@ -117,18 +123,6 @@ describe('generateTsConfig', () => {
|
||||
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')
|
||||
expect(r.dts).toContain('onlyInFake: true') // literal type, from the fake stack's features
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user