-
-
Notifications
You must be signed in to change notification settings - Fork 424
test: server utils #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
danielroe
merged 8 commits into
npmx-dev:main
from
devdumpling:dwells-test-server-utils
Jan 24, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1b0c626
chore: only allow specific versions of deps to build
devdumpling 5d9b1bb
test: file-tree convertToFileTree
devdumpling b95715b
test: import-resolver
devdumpling 75fb1b5
test: add deeper path coverage to import-resolver
devdumpling 94fb8f1
test: add more coverage to file-tree
devdumpling c2270d0
test: bit more coverage to file-tree/import-resolver to really round …
devdumpling 48bf88e
test: negative cases, cleanup a bit, add mockFetch helpers for clarity
devdumpling 16811bc
Merge branch 'main' into dwells-test-server-utils
danielroe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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[] = [ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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