diff --git a/test/unit/file-tree.spec.ts b/test/unit/file-tree.spec.ts new file mode 100644 index 0000000000..d7787bd87c --- /dev/null +++ b/test/unit/file-tree.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import type { JsDelivrFileNode, PackageFileTree } from '../../shared/types' +import { convertToFileTree, fetchFileTree, getPackageFileTree } from '../../server/utils/file-tree' + +const getChildren = (node?: PackageFileTree): PackageFileTree[] => node?.children ?? [] + +const mockFetchOk = (body: T) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => body, + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const mockFetchError = (status: number) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status, + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const mockCreateError = () => { + const createErrorMock = vi.fn((opts: { statusCode: number; message: string }) => opts) + vi.stubGlobal('createError', createErrorMock) + return createErrorMock +} + +describe('convertToFileTree', () => { + it('converts jsDelivr nodes to a sorted tree with directories first', () => { + const input: JsDelivrFileNode[] = [ + { type: 'file', name: 'zeta.txt', size: 120 }, + { + type: 'directory', + name: 'src', + files: [ + { type: 'file', name: 'b.ts', size: 5 }, + { type: 'file', name: 'a.ts', size: 3 }, + ], + }, + { type: 'file', name: 'alpha.txt', size: 10 }, + { + type: 'directory', + name: 'assets', + files: [{ type: 'file', name: 'logo.svg', size: 42 }], + }, + ] + + const tree = convertToFileTree(input) + + const names = tree.map(node => node.name) + expect(names).toEqual(['assets', 'src', 'alpha.txt', 'zeta.txt']) + + const srcNode = tree.find(node => node.name === 'src') + expect(srcNode?.type).toBe('directory') + expect(getChildren(srcNode).map(child => child.name)).toEqual(['a.ts', 'b.ts']) + }) + + it('builds correct paths and preserves file sizes', () => { + const input: JsDelivrFileNode[] = [ + { + type: 'directory', + name: 'src', + files: [ + { type: 'file', name: 'index.ts', size: 100 }, + { + type: 'directory', + name: 'utils', + files: [{ type: 'file', name: 'format.ts', size: 22 }], + }, + ], + }, + ] + + const tree = convertToFileTree(input) + + const src = tree[0] + expect(src?.path).toBe('src') + + const indexFile = getChildren(src).find(child => child.name === 'index.ts') + expect(indexFile?.path).toBe('src/index.ts') + expect(indexFile?.size).toBe(100) + + const utilsDir = getChildren(src).find(child => child.name === 'utils') + expect(utilsDir?.type).toBe('directory') + + const formatFile = getChildren(utilsDir).find(child => child.name === 'format.ts') + expect(formatFile?.path).toBe('src/utils/format.ts') + expect(formatFile?.size).toBe(22) + }) + + it('returns an empty tree for empty input', () => { + const tree = convertToFileTree([]) + const empty: PackageFileTree[] = [] + expect(tree).toEqual(empty) + }) + + it('handles directories without a files property', () => { + const input: JsDelivrFileNode[] = [ + { + type: 'directory', + name: 'src', + }, + ] + + const tree = convertToFileTree(input) + + expect(tree[0]?.type).toBe('directory') + expect(tree[0]?.children).toEqual([]) + }) +}) + +describe('fetchFileTree', () => { + it('returns parsed json when response is ok', async () => { + const body = { + type: 'npm', + name: 'pkg', + version: '1.0.0', + default: 'index.js', + files: [], + } + + mockFetchOk(body) + + try { + const result = await fetchFileTree('pkg', '1.0.0') + expect(result).toEqual(body) + } finally { + vi.unstubAllGlobals() + } + }) + + it('throws a 404 error when package is not found', async () => { + mockFetchError(404) + mockCreateError() + + try { + await expect(fetchFileTree('pkg', '1.0.0')).rejects.toMatchObject({ statusCode: 404 }) + } finally { + vi.unstubAllGlobals() + } + }) + + it('throws a 502 error for non-404 failures', async () => { + mockFetchError(500) + mockCreateError() + + try { + await expect(fetchFileTree('pkg', '1.0.0')).rejects.toMatchObject({ statusCode: 502 }) + } finally { + vi.unstubAllGlobals() + } + }) +}) + +describe('getPackageFileTree', () => { + it('returns metadata and a converted tree', async () => { + const body = { + type: 'npm', + name: 'pkg', + version: '1.0.0', + default: 'index.js', + files: [ + { + type: 'directory', + name: 'src', + files: [{ type: 'file', name: 'index.js', size: 5 }], + }, + ], + } + + mockFetchOk(body) + + try { + const result = await getPackageFileTree('pkg', '1.0.0') + expect(result.package).toBe('pkg') + expect(result.version).toBe('1.0.0') + expect(result.default).toBe('index.js') + expect(result.tree[0]?.path).toBe('src') + expect(result.tree[0]?.children?.[0]?.path).toBe('src/index.js') + } finally { + vi.unstubAllGlobals() + } + }) + + it('returns undefined when default is missing', async () => { + const body = { + type: 'npm', + name: 'pkg', + version: '1.0.0', + files: [], + } + + mockFetchOk(body) + + try { + const result = await getPackageFileTree('pkg', '1.0.0') + expect(result.default).toBeUndefined() + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/test/unit/import-resolver.spec.ts b/test/unit/import-resolver.spec.ts new file mode 100644 index 0000000000..2440239704 --- /dev/null +++ b/test/unit/import-resolver.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import type { PackageFileTree } from '../../shared/types' +import { + createImportResolver, + flattenFileTree, + resolveRelativeImport, +} from '../../server/utils/import-resolver' + +describe('flattenFileTree', () => { + it('flattens nested trees into a file set', () => { + const tree: PackageFileTree[] = [ + { + name: 'dist', + path: 'dist', + type: 'directory', + children: [ + { name: 'index.js', path: 'dist/index.js', type: 'file', size: 10 }, + { + name: 'utils', + path: 'dist/utils', + type: 'directory', + children: [{ name: 'format.js', path: 'dist/utils/format.js', type: 'file', size: 5 }], + }, + ], + }, + ] + + const files = flattenFileTree(tree) + + expect(files.has('dist/index.js')).toBe(true) + expect(files.has('dist/utils/format.js')).toBe(true) + expect(files.has('dist/utils')).toBe(false) + }) + + it('returns an empty set for an empty tree', () => { + const files = flattenFileTree([]) + expect(files.size).toBe(0) + }) + + it('includes root-level files', () => { + const tree: PackageFileTree[] = [ + { name: 'index.js', path: 'index.js', type: 'file', size: 5 }, + { name: 'cli.js', path: 'cli.js', type: 'file', size: 3 }, + ] + + const files = flattenFileTree(tree) + + expect(files.has('index.js')).toBe(true) + expect(files.has('cli.js')).toBe(true) + }) +}) + +describe('resolveRelativeImport', () => { + it('resolves a relative import with extension priority for JS files', () => { + const files = new Set(['dist/utils.js', 'dist/utils.ts']) + const resolved = resolveRelativeImport('./utils', 'dist/index.js', files) + + expect(resolved?.path).toBe('dist/utils.js') + }) + + it('resolves a relative import with extension priority for TS files', () => { + const files = new Set(['src/utils.ts', 'src/utils.js']) + const resolved = resolveRelativeImport('./utils', 'src/index.ts', files) + + expect(resolved?.path).toBe('src/utils.ts') + }) + + it('resolves a relative import to .d.ts when source is a declaration file', () => { + const files = new Set(['dist/types.d.ts', 'dist/types.ts']) + const resolved = resolveRelativeImport('./types', 'dist/index.d.ts', files) + + expect(resolved?.path).toBe('dist/types.d.ts') + }) + + it('resolves an exact extension match', () => { + const files = new Set(['src/utils.ts', 'src/utils.js']) + const resolved = resolveRelativeImport('./utils.ts', 'src/index.ts', files) + + expect(resolved?.path).toBe('src/utils.ts') + }) + + it('resolves a quoted specifier', () => { + const files = new Set(['dist/utils.js']) + const resolved = resolveRelativeImport("'./utils'", 'dist/index.js', files) + + expect(resolved?.path).toBe('dist/utils.js') + }) + + it('resolves a relative import with extension priority for MTS files', () => { + const files = new Set(['src/utils.mts', 'src/utils.mjs', 'src/utils.ts']) + const resolved = resolveRelativeImport('./utils', 'src/index.mts', files) + + expect(resolved?.path).toBe('src/utils.mts') + }) + + it('resolves a relative import with extension priority for MJS files', () => { + const files = new Set(['dist/utils.mjs', 'dist/utils.js']) + const resolved = resolveRelativeImport('./utils', 'dist/index.mjs', files) + + expect(resolved?.path).toBe('dist/utils.mjs') + }) + + it('resolves a relative import with extension priority for CTS files', () => { + const files = new Set(['src/utils.cts', 'src/utils.cjs', 'src/utils.ts']) + const resolved = resolveRelativeImport('./utils', 'src/index.cts', files) + + expect(resolved?.path).toBe('src/utils.cts') + }) + + it('resolves a relative import with extension priority for CJS files', () => { + const files = new Set(['dist/utils.cjs', 'dist/utils.js']) + const resolved = resolveRelativeImport('./utils', 'dist/index.cjs', files) + + expect(resolved?.path).toBe('dist/utils.cjs') + }) + + it('resolves directory imports to index files', () => { + const files = new Set(['dist/components/index.js']) + const resolved = resolveRelativeImport('./components', 'dist/index.js', files) + + expect(resolved?.path).toBe('dist/components/index.js') + }) + + it('resolves parent directory paths', () => { + const files = new Set(['dist/shared/helpers.js']) + const resolved = resolveRelativeImport('../shared/helpers', 'dist/pages/home.js', files) + + expect(resolved?.path).toBe('dist/shared/helpers.js') + }) + + it('returns null when the path would go above the package root', () => { + const files = new Set(['dist/index.js']) + const resolved = resolveRelativeImport('../../outside', 'dist/index.js', files) + + expect(resolved).toBeNull() + }) + + it('returns null for non-relative imports', () => { + const files = new Set(['dist/utils.js']) + const resolved = resolveRelativeImport('react', 'dist/index.js', files) + + expect(resolved).toBeNull() + }) + + it('returns null when no matching file is found', () => { + const files = new Set(['dist/utils.js']) + const resolved = resolveRelativeImport('./missing', 'dist/index.js', files) + + expect(resolved).toBeNull() + }) +}) + +describe('createImportResolver', () => { + it('creates a resolver that returns code browser URLs', () => { + const files = new Set(['dist/utils.js']) + const resolver = createImportResolver(files, 'dist/index.js', 'pkg-name', '1.2.3') + + const url = resolver('./utils') + + expect(url).toBe('/code/pkg-name/v/1.2.3/dist/utils.js') + }) + + it('returns null when the import cannot be resolved', () => { + const files = new Set(['dist/utils.js']) + const resolver = createImportResolver(files, 'dist/index.js', 'pkg-name', '1.2.3') + + const url = resolver('./missing') + + expect(url).toBeNull() + }) + + it('handles scoped package names in URLs', () => { + const files = new Set(['dist/utils.js']) + const resolver = createImportResolver(files, 'dist/index.js', '@scope/pkg', '1.2.3') + + const url = resolver('./utils') + + expect(url).toBe('/code/@scope/pkg/v/1.2.3/dist/utils.js') + }) +})