Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
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.

oops ignore this change, will pull it out--meant to only keep it in this PR: #26

will pull it shortly

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ ignoredBuiltDependencies:
- unrs-resolver

onlyBuiltDependencies:
- sharp
- simple-git-hooks
- sharp@0.33.5 || 0.34.5
- simple-git-hooks@2.13.1

ignoreWorkspaceRootCheck: true
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')
})
})