Skip to content

Commit d8e1122

Browse files
committed
Add comprehensive package.json exports validation tests
1 parent 0473791 commit d8e1122

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/** @fileoverview Test that all built files are properly exported in package.json. */
2+
3+
import { existsSync } from 'node:fs'
4+
import path from 'node:path'
5+
6+
import fastGlob from 'fast-glob'
7+
import { describe, expect, it } from 'vitest'
8+
9+
import { readPackageJson } from '../../registry/dist/lib/packages.js'
10+
11+
const registryPkgPath = path.resolve(import.meta.dirname, '../../registry')
12+
13+
describe('package.json exports validation', () => {
14+
it('should have exports for all dist files listed in "files" field', async () => {
15+
const pkgJson = await readPackageJson(registryPkgPath)
16+
17+
expect(pkgJson).toBeDefined()
18+
expect(pkgJson?.exports).toBeDefined()
19+
expect(pkgJson?.['files']).toBeDefined()
20+
21+
const exports = pkgJson?.exports
22+
const files = pkgJson?.['files']
23+
24+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
25+
throw new Error('exports should be an object')
26+
}
27+
28+
if (!Array.isArray(files)) {
29+
throw new Error('files should be an array')
30+
}
31+
32+
const exportPaths = new Set<string>()
33+
34+
const extractFilePaths = (value: any): void => {
35+
if (typeof value === 'string') {
36+
if (value.startsWith('./')) {
37+
exportPaths.add(value.slice(2))
38+
}
39+
} else if (value && typeof value === 'object') {
40+
for (const v of Object.values(value)) {
41+
extractFilePaths(v)
42+
}
43+
}
44+
}
45+
46+
for (const value of Object.values(exports)) {
47+
extractFilePaths(value)
48+
}
49+
50+
const distPatterns = files.filter(pattern => pattern.startsWith('dist/'))
51+
expect(distPatterns.length).toBeGreaterThan(0)
52+
53+
const actualDistFiles = await fastGlob.glob(distPatterns, {
54+
cwd: registryPkgPath,
55+
ignore: ['**/*.map', '**/node_modules/**', '**/dist/external/**'],
56+
})
57+
58+
expect(actualDistFiles.length).toBeGreaterThan(0)
59+
60+
const missingFromExports: string[] = []
61+
62+
for (const file of actualDistFiles) {
63+
if (
64+
file.endsWith('.js') ||
65+
file.endsWith('.d.ts') ||
66+
file.endsWith('.cjs')
67+
) {
68+
if (!exportPaths.has(file)) {
69+
missingFromExports.push(file)
70+
}
71+
}
72+
}
73+
74+
expect(
75+
missingFromExports,
76+
`All files matching "files" patterns should be in exports. Missing: ${missingFromExports.slice(0, 10).join(', ')}${missingFromExports.length > 10 ? ` and ${missingFromExports.length - 10} more` : ''}`,
77+
).toEqual([])
78+
79+
for (const file of Array.from(exportPaths)) {
80+
const fullPath = path.join(registryPkgPath, file)
81+
expect(
82+
existsSync(fullPath),
83+
`Export path "${file}" should exist at ${fullPath}`,
84+
).toBe(true)
85+
}
86+
})
87+
88+
it('should export ./lib/dependencies', async () => {
89+
const pkgJson = await readPackageJson(registryPkgPath)
90+
91+
expect(pkgJson?.exports).toBeDefined()
92+
93+
const exports = pkgJson?.exports
94+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
95+
throw new Error('exports should be an object')
96+
}
97+
98+
const dependenciesExport = exports['./lib/dependencies']
99+
expect(dependenciesExport).toBeDefined()
100+
expect(dependenciesExport).toMatchObject({
101+
types: './dist/lib/dependencies/index.d.ts',
102+
default: './dist/lib/dependencies/index.js',
103+
})
104+
105+
const typesPath = path.join(
106+
registryPkgPath,
107+
'dist/lib/dependencies/index.d.ts',
108+
)
109+
const jsPath = path.join(registryPkgPath, 'dist/lib/dependencies/index.js')
110+
111+
expect(
112+
existsSync(typesPath),
113+
`Types file should exist at ${typesPath}`,
114+
).toBe(true)
115+
expect(existsSync(jsPath), `JS file should exist at ${jsPath}`).toBe(true)
116+
})
117+
118+
it('should have all dependencies submodules exported', async () => {
119+
const pkgJson = await readPackageJson(registryPkgPath)
120+
const exports = pkgJson?.exports
121+
122+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
123+
throw new Error('exports should be an object')
124+
}
125+
126+
const dependenciesSubmodules = [
127+
'./lib/dependencies',
128+
'./lib/dependencies/build-tools',
129+
'./lib/dependencies/file-system',
130+
'./lib/dependencies/index',
131+
'./lib/dependencies/logging',
132+
'./lib/dependencies/npm-tools',
133+
'./lib/dependencies/prompts',
134+
'./lib/dependencies/system',
135+
'./lib/dependencies/validation',
136+
]
137+
138+
for (const submodule of dependenciesSubmodules) {
139+
expect(
140+
exports[submodule],
141+
`Export "${submodule}" should be defined`,
142+
).toBeDefined()
143+
144+
const exportValue = exports[submodule]
145+
expect(exportValue).toHaveProperty('types')
146+
expect(exportValue).toHaveProperty('default')
147+
148+
if (
149+
typeof exportValue === 'object' &&
150+
exportValue !== null &&
151+
'types' in exportValue &&
152+
'default' in exportValue &&
153+
typeof exportValue.types === 'string' &&
154+
typeof exportValue.default === 'string'
155+
) {
156+
const typesPath = path.join(registryPkgPath, exportValue.types.slice(2))
157+
const defaultPath = path.join(
158+
registryPkgPath,
159+
exportValue.default.slice(2),
160+
)
161+
162+
expect(
163+
existsSync(typesPath),
164+
`Types file for "${submodule}" should exist at ${typesPath}`,
165+
).toBe(true)
166+
expect(
167+
existsSync(defaultPath),
168+
`Default file for "${submodule}" should exist at ${defaultPath}`,
169+
).toBe(true)
170+
}
171+
}
172+
})
173+
174+
it('should not have exports pointing to non-existent files', async () => {
175+
const pkgJson = await readPackageJson(registryPkgPath)
176+
const exports = pkgJson?.exports
177+
178+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
179+
throw new Error('exports should be an object')
180+
}
181+
182+
const missingFiles: string[] = []
183+
184+
const checkFilePath = (filePath: string): void => {
185+
if (typeof filePath === 'string' && filePath.startsWith('./')) {
186+
const fullPath = path.join(registryPkgPath, filePath.slice(2))
187+
if (!existsSync(fullPath)) {
188+
missingFiles.push(filePath)
189+
}
190+
}
191+
}
192+
193+
const traverseExports = (value: any): void => {
194+
if (typeof value === 'string') {
195+
checkFilePath(value)
196+
} else if (value && typeof value === 'object') {
197+
for (const v of Object.values(value)) {
198+
traverseExports(v)
199+
}
200+
}
201+
}
202+
203+
for (const value of Object.values(exports)) {
204+
traverseExports(value)
205+
}
206+
207+
expect(
208+
missingFiles,
209+
`All exported files should exist. Missing: ${missingFiles.join(', ')}`,
210+
).toEqual([])
211+
})
212+
213+
it('should have types field for all code exports', async () => {
214+
const pkgJson = await readPackageJson(registryPkgPath)
215+
const exports = pkgJson?.exports
216+
217+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
218+
throw new Error('exports should be an object')
219+
}
220+
221+
const exportsWithoutTypes: string[] = []
222+
223+
for (const { 0: key, 1: value } of Object.entries(exports)) {
224+
if (key.endsWith('.json')) {
225+
continue
226+
}
227+
228+
if (
229+
typeof value === 'object' &&
230+
value !== null &&
231+
!Array.isArray(value)
232+
) {
233+
if (!('types' in value)) {
234+
exportsWithoutTypes.push(key)
235+
}
236+
}
237+
}
238+
239+
expect(
240+
exportsWithoutTypes,
241+
`All code exports should have types field. Missing: ${exportsWithoutTypes.join(', ')}`,
242+
).toEqual([])
243+
})
244+
})

0 commit comments

Comments
 (0)