Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions test/unit/file-tree.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(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
}

Comment on lines +7 to +24
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could move these out for re-use but haven't looked at all the testing patterns in place yet in full

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[] = [
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super familiar with jsDeliver. LMK if this is too rudimentary.

{ 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()
}
})
})
180 changes: 180 additions & 0 deletions test/unit/import-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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')
})
})