Files
bsky-social-app/plugins/babel-plugin-lexicon-leaf-imports/__tests__/lexiconLeafImports.test.js
2026-09-07 14:31:01 +02:00

636 lines
22 KiB
JavaScript

/*
* Tests for babel-plugin-lexicon-leaf-imports, in three layers:
*
* 1. Transform unit tests: rewrite / bail behavior on small snippets.
*
* 2. App-source proof: enumerate every chain the barrels expose, transform a
* probe referencing all of them, and typecheck it with the app tsconfig -
* proving every rewritten specifier resolves to a real module.
*
* 3. SDK dist proof: transform @bsky/sdk's compiled JS and typecheck it with
* checkJs against the shipped .d.ts, via a temp mirror resolved back into
* the real dist with rootDirs (tsc never checks JS under node_modules).
* checkJs is noisy on compiled output, so diagnostics are diffed against
* an untransformed baseline: only plugin-introduced ones fail.
*/
const {transformSync} = require('@babel/core')
const {parse} = require('@babel/parser')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const ts = require('typescript')
const plugin = require('..')
const {barrelExports, resolveModuleFile} = require('../lexiconBarrels')
const ROOT = path.resolve(__dirname, '../../..')
const LEXICONS_ROOT = path.join(ROOT, 'src', 'lexicons')
const SDK_DIST = path.join(ROOT, 'node_modules', '@bsky', 'sdk', 'dist')
const TYPECHECK_TIMEOUT_MS = 240_000
function applyPlugin(code, filename) {
return transformSync(code, {
filename,
configFile: false,
babelrc: false,
parserOpts: {plugins: ['typescript']},
plugins: [[plugin, {roots: [LEXICONS_ROOT]}]],
}).code
}
/** A virtual .ts path inside src/ - the file itself never exists on disk. */
const PROBE_FILE = path.join(ROOT, 'src', '__lexicon_leaf_probe__.ts')
describe('transform', () => {
test('rewrites a barrel member chain to a leaf namespace import', () => {
const out = applyPlugin(
`import {app} from './lexicons'\nvoid app.bsky.feed.like\n`,
PROBE_FILE,
)
expect(out).toContain(
`import * as _lex_app_bsky_feed_like from "./lexicons/app/bsky/feed/like"`,
)
expect(out).toContain('void _lex_app_bsky_feed_like')
expect(out).not.toContain(`from './lexicons'`)
})
test('bails when the namespace is used as a value', () => {
const src = `import {app} from './lexicons'\nconsole.log(app)\n`
const out = applyPlugin(src, PROBE_FILE)
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
})
test('bails when the chain stops at a non-leaf barrel', () => {
const src = `import {app} from './lexicons'\nvoid app.bsky\n`
const out = applyPlugin(src, PROBE_FILE)
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
})
test('bails when the chain is a write target', () => {
const writes = [
'app.bsky.feed.like = 1',
'app.bsky.feed.like++',
'delete app.bsky.feed.like',
'for (app.bsky.feed.like of []) {}',
';[app.bsky.feed.like] = []',
';({x: app.bsky.feed.like} = {})',
]
for (const stmt of writes) {
const out = applyPlugin(
`import {app} from './lexicons'\n${stmt}\n`,
PROBE_FILE,
)
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
}
})
test('rewrites a read of a leaf even when a sibling member is written', () => {
const out = applyPlugin(
`import {app} from './lexicons'\ndelete app.bsky.feed.like.$cached\n`,
PROBE_FILE,
)
expect(out).toContain('delete _lex_app_bsky_feed_like.$cached')
expect(out).not.toContain(`from './lexicons'`)
})
test('leaves type-only imports untouched', () => {
const src = `import type {app} from './lexicons'\nexport type T = typeof app\n`
const out = applyPlugin(src, PROBE_FILE)
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
})
test('inserts leaf imports at the barrel import position, not the top', () => {
const out = applyPlugin(
`import './setup'\nimport {app} from './lexicons'\nvoid app.bsky.feed.like\n`,
PROBE_FILE,
)
const setupAt = out.indexOf(`'./setup'`)
const leafAt = out.indexOf('import * as _lex_app_bsky_feed_like')
expect(setupAt).toBeGreaterThanOrEqual(0)
expect(leafAt).toBeGreaterThan(setupAt)
expect(out).not.toContain(`from './lexicons'`)
})
test('ignores imports that are not lexicon barrels', () => {
const src = `import {app} from './other'\nvoid app.bsky.feed.like\n`
const out = applyPlugin(src, PROBE_FILE)
expect(out).toContain(`from './other'`)
expect(out).toContain('void app.bsky.feed.like')
expect(out).not.toContain('import *')
})
})
/*
* The plugin's module-level caches outlive individual transforms, so a
* lexicon regen inside a long-lived worker (Metro, jest --watch) must
* invalidate them - the plugin uses the root index mtime as the epoch.
*/
describe('cache invalidation across a lexicon regen', () => {
let tmp
let lexRoot
function write(rel, content) {
const file = path.join(tmp, rel)
fs.mkdirSync(path.dirname(file), {recursive: true})
fs.writeFileSync(file, content)
}
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lexicon-regen-'))
lexRoot = path.join(tmp, 'lexicons')
write('lexicons/index.ts', `export * as app from './app'\n`)
write('lexicons/app.ts', `export * as bsky from './app/bsky'\n`)
write('lexicons/app/bsky.ts', `export * as feed from './bsky/feed'\n`)
write('lexicons/app/bsky/feed.ts', `export * as like from './feed/like'\n`)
write('lexicons/app/bsky/feed/like.ts', `export const $type = 'test'\n`)
})
afterAll(() => {
fs.rmSync(tmp, {recursive: true, force: true})
})
function transform() {
return transformSync(
`import {app} from './lexicons'\nvoid app.bsky.feed.like\n`,
{
filename: path.join(tmp, 'consumer.ts'),
configFile: false,
babelrc: false,
parserOpts: {plugins: ['typescript']},
plugins: [[plugin, {roots: [lexRoot]}]],
},
).code
}
test('a layout change is picked up without a process restart', () => {
expect(transform()).toContain(
`import * as _lex_app_bsky_feed_like from "./lexicons/app/bsky/feed/like"`,
)
/*
* Simulate `lex build --clear` deepening the leaf into a barrel; bump the
* root index mtime explicitly since same-millisecond writes would hide it.
*/
write(
'lexicons/app/bsky/feed/like.ts',
`export * as main from './like/main'\n`,
)
write(
'lexicons/app/bsky/feed/like/main.ts',
`export const $type = 'test'\n`,
)
const bumped = new Date(Date.now() + 10_000)
fs.utimesSync(path.join(lexRoot, 'index.ts'), bumped, bumped)
/*
* The chain now stops at a barrel: fresh caches bail, stale caches would
* replay the rewrite against the old layout.
*/
const out = transform()
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
})
})
/**
* Follow `export * as` through the barrel graph, mirroring the plugin's own
* leaf/barrel rule (a sibling directory means barrel). Yields chains like
* ['app', 'bsky', 'feed', 'like'].
*/
function collectLeafChains(rootDir) {
const chains = []
function resolveTarget(fromFile, spec) {
const abs = resolveModuleFile(fromFile, spec)
if (!abs) throw new Error(`cannot resolve '${spec}' from ${fromFile}`)
return abs
}
function walk(barrelFile, segments) {
const exports = barrelExports(barrelFile)
if (!exports) {
throw new Error(`${barrelFile} is not a pure namespace barrel`)
}
for (const [name, spec] of exports) {
const target = resolveTarget(barrelFile, spec)
const asDir = target.replace(/\.(ts|js)$/, '')
if (fs.existsSync(asDir) && fs.statSync(asDir).isDirectory()) {
walk(target, [...segments, name])
} else {
chains.push([...segments, name])
}
}
}
walk(resolveTarget(path.join(rootDir, 'index.ts'), './index'), [])
return chains
}
function formatDiagnostics(diags) {
return diags
.slice(0, 20)
.map(d => {
const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ')
if (d.file && d.start !== undefined) {
const {line} = d.file.getLineAndCharacterOfPosition(d.start)
return `${d.file.fileName}:${line + 1} TS${d.code}: ${msg}`
}
return `TS${d.code}: ${msg}`
})
.join('\n')
}
function fileDiagnostics(program, sourceFile) {
return [
...program.getSyntacticDiagnostics(sourceFile),
...program.getSemanticDiagnostics(sourceFile),
].filter(d => d.category === ts.DiagnosticCategory.Error)
}
function loadAppCompilerOptions() {
const configPath = path.join(ROOT, 'tsconfig.json')
const config = ts.readConfigFile(configPath, ts.sys.readFile).config
const parsed = ts.parseJsonConfigFileContent(
{...config, include: [], files: []},
ts.sys,
ROOT,
undefined,
configPath,
)
return {...parsed.options, noEmit: true, skipLibCheck: true}
}
/** A CompilerHost serving in-memory content for the paths in `overlays`. */
function createOverlayHost(options, overlays) {
const host = ts.createCompilerHost(options)
const origGetSourceFile = host.getSourceFile.bind(host)
const origFileExists = host.fileExists.bind(host)
const origReadFile = host.readFile.bind(host)
host.fileExists = f => overlays.has(f) || origFileExists(f)
host.readFile = f => overlays.get(f) ?? origReadFile(f)
host.getSourceFile = (f, lang, ...rest) =>
overlays.has(f)
? ts.createSourceFile(f, overlays.get(f), lang)
: origGetSourceFile(f, lang, ...rest)
return host
}
describe('app sources: every barrel chain rewrites and typechecks', () => {
test(
'probe referencing all leaf chains',
() => {
const chains = collectLeafChains(LEXICONS_ROOT)
/* The tree is large; a sudden collapse means the walker broke. */
expect(chains.length).toBeGreaterThan(100)
const barrelRoots = [...new Set(chains.map(c => c[0]))]
const probeSource =
`import {${barrelRoots.join(', ')}} from './lexicons'\n` +
chains.map(c => `void ${c.join('.')}`).join('\n') +
'\n'
/* Also runs the plugin's verifyChain proof for every chain. */
const out = applyPlugin(probeSource, PROBE_FILE)
expect(out).not.toContain(`from './lexicons'`)
const leafImports = out.match(/from "\.\/lexicons\//g) ?? []
expect(leafImports).toHaveLength(chains.length)
/*
* Typecheck the probe overlaid at a virtual src/ path, so its relative
* leaf imports resolve against the real tree.
*/
const options = loadAppCompilerOptions()
const host = createOverlayHost(options, new Map([[PROBE_FILE, out]]))
const program = ts.createProgram([PROBE_FILE], options, host)
const probeSf = program.getSourceFile(PROBE_FILE)
expect(probeSf).toBeDefined()
const errors = fileDiagnostics(program, probeSf)
if (errors.length > 0) {
throw new Error(
`transformed probe has type errors:\n${formatDiagnostics(errors)}`,
)
}
/*
* Canary: a bad specifier must be flagged, so a broken overlay host
* cannot produce a vacuous pass.
*/
const canary =
out + `import * as _bad from './lexicons/app/bsky/feed/__nope__'\n`
const canaryHost = createOverlayHost(
options,
new Map([[PROBE_FILE, canary]]),
)
const canaryProgram = ts.createProgram([PROBE_FILE], options, canaryHost)
const canaryDiags = fileDiagnostics(
canaryProgram,
canaryProgram.getSourceFile(PROBE_FILE),
)
expect(canaryDiags.map(d => d.code)).toContain(2307)
},
TYPECHECK_TIMEOUT_MS,
)
})
describe('@bsky/sdk dist: rewrites typecheck against shipped .d.ts', () => {
let tmpDir
afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, {recursive: true, force: true})
})
function findBarrelImporters(dir) {
const found = []
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
found.push(...findBarrelImporters(full))
} else if (
entry.name.endsWith('.js') &&
/^import[^\n]*['"][^'"]*lexicons\/index\.js['"]/m.test(
fs.readFileSync(full, 'utf8'),
)
) {
found.push(full)
}
}
return found
}
test(
'transformed dist files introduce no new diagnostics',
() => {
const importers = findBarrelImporters(SDK_DIST)
expect(importers.length).toBeGreaterThan(0)
const transformed = []
for (const file of importers) {
const code = fs.readFileSync(file, 'utf8')
/* Real filename so the plugin's @bsky/sdk path detection triggers. */
const out = applyPlugin(code, file)
if (out.trim() !== code.trim()) {
transformed.push({file, original: code, shadow: out})
}
}
expect(transformed.length).toBeGreaterThan(0)
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lexicon-leafcheck-'))
const mirrors = {
shadow: path.join(tmpDir, 'shadow'),
baseline: path.join(tmpDir, 'baseline'),
}
for (const kind of ['shadow', 'baseline']) {
fs.mkdirSync(mirrors[kind], {recursive: true})
fs.writeFileSync(
path.join(mirrors[kind], 'package.json'),
'{"type": "module"}\n',
)
}
const mirrorPath = (kind, file) =>
path.join(mirrors[kind], 'dist', path.relative(SDK_DIST, file))
for (const {file, original, shadow} of transformed) {
for (const [kind, code] of [
['shadow', shadow],
['baseline', original],
]) {
const dest = mirrorPath(kind, file)
fs.mkdirSync(path.dirname(dest), {recursive: true})
fs.writeFileSync(dest, code)
}
}
function diagnose(kind) {
const options = {
allowJs: true,
checkJs: true,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
/*
* Relative imports in the mirror resolve into the real dist,
* landing on its .d.ts files.
*/
rootDirs: [path.join(mirrors[kind], 'dist'), SDK_DIST],
}
const roots = transformed.map(t => mirrorPath(kind, t.file))
const program = ts.createProgram(roots, options)
const byFile = new Map()
for (const t of transformed) {
const sf = program.getSourceFile(mirrorPath(kind, t.file))
if (!sf) {
throw new Error(`${mirrorPath(kind, t.file)} missing from program`)
}
byFile.set(t.file, fileDiagnostics(program, sf))
}
return byFile
}
/*
* Canary: a known-bad member access must be flagged, proving checkJs
* actually checks through the SDK's .d.ts files.
*/
{
const canary = path.join(mirrors.shadow, 'dist', '__canary__.js')
fs.writeFileSync(
canary,
`import * as leaf from './lexicons/index.js'\nvoid leaf.__does_not_exist__\n`,
)
const options = {
allowJs: true,
checkJs: true,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
rootDirs: [path.join(mirrors.shadow, 'dist'), SDK_DIST],
}
const program = ts.createProgram([canary], options)
const diags = fileDiagnostics(program, program.getSourceFile(canary))
expect(diags.map(d => d.code)).toContain(2339)
fs.rmSync(canary)
}
const baseline = diagnose('baseline')
const shadow = diagnose('shadow')
const diagKey = d =>
`TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`
const regressions = []
for (const {file} of transformed) {
const known = new Set(baseline.get(file).map(diagKey))
for (const d of shadow.get(file)) {
if (!known.has(diagKey(d))) regressions.push(d)
}
}
if (regressions.length > 0) {
throw new Error(
`plugin introduced diagnostics in @bsky/sdk dist:\n${formatDiagnostics(regressions)}`,
)
}
},
TYPECHECK_TIMEOUT_MS,
)
})
describe('app callsites: transformed sources typecheck', () => {
/*
* The real-usage complement to the probe: transform every app file that
* imports the barrel - with babel-plugin-module-resolver ahead of the
* plugin, as in babel.config.js - and typecheck the output in place of the
* originals.
*
* Types are kept (no preset-typescript), so unlike production the type
* positions still need a specifier the plugin drops; those names are
* re-added as a type-only barrel import - their production status anyway.
*
* Diagnostics are diffed against a baseline of the same file through the
* identical pipeline without the plugin, so reprinting artifacts affect
* both sides equally and diff out - only plugin-caused differences fail.
*/
test(
'every file importing the barrel',
() => {
const consumers = []
;(function walk(dir) {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (
/\.tsx?$/.test(entry.name) &&
!entry.name.endsWith('.d.ts') &&
fs.readFileSync(full, 'utf8').includes(`from '#/lexicons'`)
) {
consumers.push(full)
}
}
})(path.join(ROOT, 'src'))
expect(consumers.length).toBeGreaterThan(100)
/** Names bound by value imports of the barrel, keyed off any path form. */
function barrelImportNames(code, file) {
const names = new Set()
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
})
for (const stmt of ast.program.body) {
if (stmt.type !== 'ImportDeclaration') continue
const source = stmt.source.value
const abs = source.startsWith('.')
? path
.resolve(path.dirname(file), source)
.replace(/[\\/]index$/, '')
: null
if (source !== '#/lexicons' && abs !== LEXICONS_ROOT) continue
for (const spec of stmt.specifiers) {
if (spec.type === 'ImportSpecifier') names.add(spec.local.name)
}
}
return names
}
function transformConsumer(code, file, withPlugin) {
let out = transformSync(code, {
filename: file,
cwd: ROOT,
configFile: false,
babelrc: false,
parserOpts: {plugins: ['typescript', 'jsx']},
plugins: [
[
require.resolve('babel-plugin-module-resolver'),
{alias: {'#': './src', crypto: './src/platform/crypto.ts'}},
],
...(withPlugin ? [[plugin, {roots: [LEXICONS_ROOT]}]] : []),
],
}).code
const dropped = [...barrelImportNames(code, file)].filter(
n => !barrelImportNames(out, file).has(n),
)
if (dropped.length > 0) {
out = `import type {${dropped.join(', ')}} from '#/lexicons'\n` + out
}
return out
}
/*
* A shadow byte-identical to its baseline cannot produce a diagnostics
* diff, so only changed files are overlaid and checked.
*/
const shadowOverlays = new Map()
const baselineOverlays = new Map()
const changed = []
let rewritten = 0
for (const file of consumers) {
const code = fs.readFileSync(file, 'utf8')
const shadow = transformConsumer(code, file, true)
const baseline = transformConsumer(code, file, false)
if (shadow === baseline) continue
changed.push(file)
shadowOverlays.set(file, shadow)
baselineOverlays.set(file, baseline)
if (shadow.includes('_lex_')) rewritten++
}
expect(rewritten).toBeGreaterThan(100)
const options = loadAppCompilerOptions()
/*
* One program holds both versions: the baseline at the real path, the
* shadow at a virtual `.__shadow__.` sibling (same dir and extension,
* so import resolution behaves identically). This parses the shared
* app + node_modules closure once - the dominant cost - where separate
* programs would each redo it.
*/
const shadowPath = f => f.replace(/\.(tsx?)$/, '.__shadow__.$1')
const overlays = new Map()
for (const file of changed) {
overlays.set(file, baselineOverlays.get(file))
overlays.set(shadowPath(file), shadowOverlays.get(file))
}
const host = createOverlayHost(options, overlays)
const program = ts.createProgram([...overlays.keys()], options, host)
/*
* Normalize the virtual path spelling inside messages so it alone
* never counts as a regression.
*/
const diagKey = d =>
`TS${d.code}: ${ts
.flattenDiagnosticMessageText(d.messageText, ' ')
.replaceAll('.__shadow__.', '.')}`
const regressions = []
for (const file of changed) {
const baselineSf = program.getSourceFile(file)
const shadowSf = program.getSourceFile(shadowPath(file))
if (!baselineSf || !shadowSf) {
throw new Error(`${file} missing from program`)
}
const known = new Set(fileDiagnostics(program, baselineSf).map(diagKey))
for (const d of fileDiagnostics(program, shadowSf)) {
if (!known.has(diagKey(d))) regressions.push(d)
}
}
if (regressions.length > 0) {
throw new Error(
`plugin introduced diagnostics in app sources:\n${formatDiagnostics(regressions)}`,
)
}
},
TYPECHECK_TIMEOUT_MS,
)
})