mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-25 23:17:08 -04:00
Allow consumers (e.g. Bit CLI) to provide a nameFormatter callback that reads the package manifest and returns a custom display name. The resolved displayName is carried through the DependentsTree/DependentNode data model and used by all render functions (tree, JSON, parseable).
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import { stripVTControlCharacters as stripAnsi } from 'util'
|
|
import { renderDependentsTree, renderDependentsJson, renderDependentsParseable } from '../lib/renderDependentsTree.js'
|
|
import { type DependentsTree } from '@pnpm/reviewing.dependencies-hierarchy'
|
|
|
|
// Shared fixture: target → mid-a → root-project (2 levels of dependents)
|
|
function deepTree (): DependentsTree[] {
|
|
return [
|
|
{
|
|
name: 'target',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{
|
|
name: 'mid-a',
|
|
version: '2.0.0',
|
|
dependents: [
|
|
{ name: 'root-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
{ name: 'root-project', version: '0.0.0', depField: 'devDependencies' },
|
|
],
|
|
},
|
|
]
|
|
}
|
|
|
|
describe('renderDependentsTree', () => {
|
|
test('renders searchMessage below the root label', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
searchMessage: 'Matched by custom finder',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
const lines = output.split('\n')
|
|
|
|
// Root label should be the package name@version
|
|
expect(lines[0]).toContain('foo@1.0.0')
|
|
// Search message should appear on a subsequent line
|
|
expect(lines.some(l => l.includes('Matched by custom finder'))).toBe(true)
|
|
// Dependent should still be rendered
|
|
expect(lines.some(l => l.includes('my-project@0.0.0'))).toBe(true)
|
|
})
|
|
|
|
test('does not render extra line when searchMessage is undefined', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
const lines = output.split('\n')
|
|
|
|
expect(lines[0]).toBe('foo@1.0.0')
|
|
// Second line should be part of the tree, not a message
|
|
expect(lines[1]).not.toBe('')
|
|
expect(lines[1]).toContain('my-project')
|
|
})
|
|
|
|
test('depth limits how deep the tree is rendered', async () => {
|
|
const withDepth = stripAnsi(await renderDependentsTree(deepTree(), { long: false, depth: 1 }))
|
|
const withoutDepth = stripAnsi(await renderDependentsTree(deepTree(), { long: false }))
|
|
|
|
// Without depth, root-project appears twice: once nested under mid-a, once as direct dependent
|
|
const fullLines = withoutDepth.split('\n')
|
|
const rootProjectOccurrences = fullLines.filter(l => l.includes('root-project@0.0.0'))
|
|
expect(rootProjectOccurrences).toHaveLength(2)
|
|
|
|
// With depth 1, mid-a's children are not expanded, so root-project appears only once (as direct dependent)
|
|
const limitedLines = withDepth.split('\n')
|
|
const limitedRootProjectOccurrences = limitedLines.filter(l => l.includes('root-project@0.0.0'))
|
|
expect(limitedRootProjectOccurrences).toHaveLength(1)
|
|
// mid-a should still be visible
|
|
expect(withDepth).toContain('mid-a@2.0.0')
|
|
})
|
|
|
|
test('renders displayName instead of name when provided', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
displayName: 'my-component',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{
|
|
name: 'bar',
|
|
displayName: 'other-component',
|
|
version: '2.0.0',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('my-component@1.0.0')
|
|
expect(output).not.toContain('foo@1.0.0')
|
|
expect(output).toContain('other-component@2.0.0')
|
|
expect(output).not.toContain('bar@2.0.0')
|
|
// Importer without displayName should still render its name
|
|
expect(output).toContain('my-project@0.0.0')
|
|
})
|
|
|
|
test('falls back to name when displayName is undefined', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('foo@1.0.0')
|
|
})
|
|
|
|
test('renders package with no dependents and a searchMessage', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'bar',
|
|
version: '2.0.0',
|
|
searchMessage: 'Found via license check',
|
|
dependents: [],
|
|
},
|
|
]
|
|
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
const lines = output.split('\n')
|
|
|
|
expect(lines[0]).toBe('bar@2.0.0')
|
|
expect(lines[1]).toBe('Found via license check')
|
|
})
|
|
})
|
|
|
|
describe('whySummary', () => {
|
|
test('single package, single version', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
]
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('Found 1 version of foo')
|
|
expect(output).not.toContain('instances')
|
|
})
|
|
|
|
test('single package, multiple versions', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
{
|
|
name: 'foo',
|
|
version: '2.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
]
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('Found 2 versions of foo')
|
|
expect(output).not.toContain('instances')
|
|
})
|
|
|
|
test('single package, same version with multiple peer variants shows instance count', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
peersSuffixHash: 'aaaa',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
peersSuffixHash: 'bbbb',
|
|
dependents: [{ name: 'other', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
]
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('Found 1 version, 2 instances of foo')
|
|
})
|
|
|
|
test('multiple different packages each get their own summary line', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
{
|
|
name: 'bar',
|
|
version: '2.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
{
|
|
name: 'bar',
|
|
version: '3.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
]
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('Found 1 version of foo')
|
|
expect(output).toContain('Found 2 versions of bar')
|
|
})
|
|
|
|
test('summary uses displayName when provided', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
displayName: 'my-component',
|
|
version: '1.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
{
|
|
name: 'foo',
|
|
displayName: 'my-component',
|
|
version: '2.0.0',
|
|
dependents: [{ name: 'my-project', version: '0.0.0', depField: 'dependencies' }],
|
|
},
|
|
]
|
|
const output = stripAnsi(await renderDependentsTree(results, { long: false }))
|
|
expect(output).toContain('Found 2 versions of my-component')
|
|
expect(output).not.toContain('Found 2 versions of foo')
|
|
})
|
|
|
|
test('empty results produce no summary', async () => {
|
|
const output = await renderDependentsTree([], { long: false })
|
|
expect(output).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('renderDependentsJson', () => {
|
|
test('includes searchMessage in JSON output', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
searchMessage: 'Matched by custom finder',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const parsed = JSON.parse(await renderDependentsJson(results, { long: false }))
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0].searchMessage).toBe('Matched by custom finder')
|
|
})
|
|
|
|
test('depth truncates dependents in JSON output', async () => {
|
|
const parsed = JSON.parse(await renderDependentsJson(deepTree(), { long: false, depth: 1 }))
|
|
expect(parsed).toHaveLength(1)
|
|
const tree = parsed[0]
|
|
// Direct dependents (depth 0) should be present
|
|
expect(tree.dependents).toHaveLength(2)
|
|
// mid-a should have its dependents stripped (depth 1 is beyond the limit)
|
|
const midA = tree.dependents.find((d: any) => d.name === 'mid-a') // eslint-disable-line
|
|
expect(midA).toBeDefined()
|
|
expect(midA.dependents).toBeUndefined()
|
|
// root-project (direct dependent) should still be present
|
|
const root = tree.dependents.find((d: any) => d.name === 'root-project') // eslint-disable-line
|
|
expect(root).toBeDefined()
|
|
})
|
|
|
|
test('no depth option preserves full dependents in JSON output', async () => {
|
|
const parsed = JSON.parse(await renderDependentsJson(deepTree(), { long: false }))
|
|
const tree = parsed[0]
|
|
const midA = tree.dependents.find((d: any) => d.name === 'mid-a') // eslint-disable-line
|
|
expect(midA.dependents).toHaveLength(1)
|
|
expect(midA.dependents[0].name).toBe('root-project')
|
|
})
|
|
|
|
test('includes displayName in JSON output', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
displayName: 'my-component',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{
|
|
name: 'bar',
|
|
displayName: 'other-component',
|
|
version: '2.0.0',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
const parsed = JSON.parse(await renderDependentsJson(results, { long: false }))
|
|
expect(parsed[0].name).toBe('foo')
|
|
expect(parsed[0].displayName).toBe('my-component')
|
|
expect(parsed[0].dependents[0].name).toBe('bar')
|
|
expect(parsed[0].dependents[0].displayName).toBe('other-component')
|
|
// Nodes without displayName should not have the field
|
|
expect(parsed[0].dependents[0].dependents[0].displayName).toBeUndefined()
|
|
})
|
|
|
|
test('does not include searchMessage when undefined', async () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
dependents: [],
|
|
},
|
|
]
|
|
|
|
const parsed = JSON.parse(await renderDependentsJson(results, { long: false }))
|
|
expect(parsed[0].searchMessage).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('renderDependentsParseable', () => {
|
|
test('depth limits parseable output depth', () => {
|
|
const output = renderDependentsParseable(deepTree(), { long: false, depth: 1 })
|
|
const lines = output.split('\n')
|
|
// With depth 1, mid-a cannot recurse further — it becomes a leaf
|
|
// So we should get two lines:
|
|
// 1. mid-a > target (mid-a treated as leaf since depth prevents expanding its children)
|
|
// 2. root-project > target (direct dependent)
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines.some(l => l === 'mid-a@2.0.0 > target@1.0.0')).toBe(true)
|
|
expect(lines.some(l => l === 'root-project@0.0.0 > target@1.0.0')).toBe(true)
|
|
})
|
|
|
|
test('no depth option renders full paths in parseable output', () => {
|
|
const output = renderDependentsParseable(deepTree(), { long: false })
|
|
const lines = output.split('\n')
|
|
// Without depth limit, mid-a is expanded to root-project
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines.some(l => l === 'root-project@0.0.0 > mid-a@2.0.0 > target@1.0.0')).toBe(true)
|
|
expect(lines.some(l => l === 'root-project@0.0.0 > target@1.0.0')).toBe(true)
|
|
})
|
|
|
|
test('uses displayName in parseable output', () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'foo',
|
|
displayName: 'my-component',
|
|
version: '1.0.0',
|
|
dependents: [
|
|
{
|
|
name: 'bar',
|
|
displayName: 'other-component',
|
|
version: '2.0.0',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = renderDependentsParseable(results, { long: false })
|
|
const lines = output.split('\n')
|
|
expect(lines).toHaveLength(1)
|
|
expect(lines[0]).toBe('my-project@0.0.0 > other-component@2.0.0 > my-component@1.0.0')
|
|
})
|
|
|
|
test('renders parseable output with searchMessage result', () => {
|
|
const results: DependentsTree[] = [
|
|
{
|
|
name: 'dep-a',
|
|
version: '1.0.0',
|
|
searchMessage: 'Found via custom check',
|
|
dependents: [
|
|
{ name: 'my-project', version: '0.0.0', depField: 'dependencies' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const output = renderDependentsParseable(results, { long: false })
|
|
const lines = output.split('\n')
|
|
// Parseable output should still contain the path
|
|
expect(lines).toHaveLength(1)
|
|
expect(lines[0]).toContain('dep-a@1.0.0')
|
|
expect(lines[0]).toContain('my-project@0.0.0')
|
|
})
|
|
})
|