Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const extractArtifactId = (dependency: string): string =>
export type DependencyTreeData = {
name: string;
children?: DependencyTreeData[];
cycle?: boolean;
};

type HierarchyNodeData = HierarchyNode<DependencyTreeData>;
Expand Down Expand Up @@ -189,7 +190,8 @@ const updateDependencyTree = async (
.attr('ry', 6)
.attr('stroke-width', 1)
.attr('fill-opacity', 0.8)
.attr('class', (d) => `node ${d._children ? 'node-with-children' : ''}`);
.attr('class', (d) => `node ${d._children ? 'node-with-children' : ''}`)
.classed('node-cycle', (d) => !!d.data.cycle);

nodeEnter
.append('text')
Expand All @@ -205,7 +207,9 @@ const updateDependencyTree = async (
.attr('dy', '.75rem')
.attr('x', 12)
.attr('text-anchor', 'start')
.text((d) => extractArtifactId(d.data.name));
.text(
(d) => `${d.data.cycle ? '↩ ' : ''}${extractArtifactId(d.data.name)}`,
);

nodeEnter
.on('mouseover', (event, d) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"legend": {
"title": "Legend",
"node": "Dependency with no further dependencies",
"node_with_children": "Dependency with other dependencies"
"node_with_children": "Dependency with other dependencies",
"cycle": "Cycle reference (already shown on this path)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"legend": {
"title": "圖例",
"node": "無其他相依元件的相依項",
"node_with_children": "有其他相依元件的相依項"
"node_with_children": "有其他相依元件的相依項",
"cycle": "循環參照(已顯示於此路徑)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,129 @@ import { describe, expect, it } from 'vitest';
import {
SbomDependency,
filterTree,
normalizeData,
normalizeNodeName,
retrieveChildren,
} from './sbomUtils';

import { DependencyTreeData } from '@/views/instances/sbomdependencytrees/dependencyTree';

describe('cyclic dependencies', () => {
const graph = [
{ ref: 'app', dependsOn: ['a'] },
{ ref: 'a', dependsOn: ['b'] },
{ ref: 'b', dependsOn: ['a'] },
];

it('terminates a reachable cycle without modifying the graph', () => {
const original = structuredClone(graph);
const tree = normalizeData(graph);
expect(tree).toEqual({
name: 'app',
children: [
{
name: 'a',
children: [{ name: 'b', children: [{ name: 'a', cycle: true }] }],
},
],
});
expect(() => JSON.stringify(tree)).not.toThrow();
expect(graph).toEqual(original);
});

it('terminates a self-reference at the root', () => {
expect(normalizeData([{ ref: 'app', dependsOn: ['app'] }])).toEqual({
name: 'app',
children: [{ name: 'app', cycle: true }],
});
});

it('includes the root in the ancestor path', () => {
expect(
normalizeData([
{ ref: 'app', dependsOn: ['a'] },
{ ref: 'a', dependsOn: ['app'] },
]),
).toEqual({
name: 'app',
children: [{ name: 'a', children: [{ name: 'app', cycle: true }] }],
});
});

it('retains shared dependencies under independent parents', () => {
expect(
normalizeData([
{ ref: 'app', dependsOn: ['a', 'b'] },
{ ref: 'a', dependsOn: ['c'] },
{ ref: 'b', dependsOn: ['c'] },
{ ref: 'c', dependsOn: ['d'] },
{ ref: 'd', dependsOn: [] },
]),
).toEqual({
name: 'app',
children: ['a', 'b'].map((name) => ({
name,
children: [
{ name: 'c', children: [{ name: 'd', children: undefined }] },
],
})),
});
});

it('compares full references rather than normalized labels', () => {
const jar = 'pkg:maven/example/lib@1?type=jar';
const pom = 'pkg:maven/example/lib@1?type=pom';
expect(
normalizeData([
{ ref: jar, dependsOn: [pom] },
{ ref: pom, dependsOn: [jar] },
]),
).toEqual({
name: 'example/lib@1',
children: [
{
name: 'example/lib@1',
children: [{ name: 'example/lib@1', cycle: true }],
},
],
});
});

it('also protects direct calls to retrieveChildren', () => {
expect(retrieveChildren(['a'], [{ ref: 'a', dependsOn: ['a'] }])).toEqual([
{ name: 'a', children: [{ name: 'a', cycle: true }] },
]);
});

it('keeps noncyclic siblings and tolerates missing dependency records', () => {
expect(
normalizeData([
{ ref: 'app', dependsOn: ['a', 'missing', 'leaf'] },
{ ref: 'a', dependsOn: ['a'] },
{ ref: 'leaf' },
]),
).toEqual({
name: 'app',
children: [
{ name: 'a', children: [{ name: 'a', cycle: true }] },
{ name: 'missing', children: undefined },
{ name: 'leaf', children: undefined },
],
});
});

it('filters a finite tree while preserving cycle markers', () => {
const tree = normalizeData(graph);
expect(filterTree(tree, 'a')?.children[0].children[0].children[0]).toEqual({
name: 'a',
cycle: true,
children: undefined,
});
expect(filterTree(tree, 'no-match')).toBeNull();
expect(filterTree(tree, '')).toBe(tree);
});
});

describe('normalizeNodeName', () => {
it('should remove the path before the first forward slash', () => {
const input = 'path/to/resource';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,52 @@ export type SbomDependency = {
export const normalizeNodeName = (name: string): string =>
name.replace(/^[^\/]*\//, '').replace(/\?.*$/, '');

function getChildren(sbomDependencies: SbomDependency[], item: string) {
return retrieveChildren(
sbomDependencies
.filter((node) => node.ref === item && node.dependsOn.length)
.flatMap((node) => node.dependsOn),
sbomDependencies,
);
function expandDependency(
ref: string,
dependenciesByRef: Map<string, SbomDependency>,
ancestors: Set<string>,
): DependencyTreeData {
const name = normalizeNodeName(ref);
if (ancestors.has(ref)) return { name, cycle: true };

// Only ancestors on this path form a cycle; shared dependencies in other
// branches must still be expanded. Keep full references for identity.
const path = new Set(ancestors).add(ref);
const dependsOn = dependenciesByRef.get(ref)?.dependsOn ?? [];
return {
name,
children: dependsOn.length
? dependsOn.map((child) =>
expandDependency(child, dependenciesByRef, path),
)
: undefined,
};
}

export const retrieveChildren = (
dependsOn: string[],
sbomDependencies: SbomDependency[],
ancestors: Set<string> = new Set(),
): DependencyTreeData[] | undefined => {
if (!dependsOn.length) return undefined;

return dependsOn.map((item) => ({
name: normalizeNodeName(item),
children: getChildren(sbomDependencies, item),
}));
const dependenciesByRef = new Map(
sbomDependencies.map((dependency) => [dependency.ref, dependency]),
);
return dependsOn.map((item) =>
expandDependency(item, dependenciesByRef, ancestors),
);
};

export const normalizeData = (
sbomDependencies: SbomDependency[],
): DependencyTreeData => {
const children = sbomDependencies[0].dependsOn.map((item) => ({
name: normalizeNodeName(item),
children: getChildren(sbomDependencies, item),
}));
const children =
retrieveChildren(
sbomDependencies[0].dependsOn ?? [],
sbomDependencies,
new Set([sbomDependencies[0].ref]),
) ?? [];

return {
name: normalizeNodeName(sbomDependencies[0].ref),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,69 @@
import { RenderResult, screen, waitFor } from '@testing-library/vue';
import { RenderResult, fireEvent, screen, waitFor } from '@testing-library/vue';
import { HttpResponse, http } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { applications } from '@/mocks/applications/data';
import { server } from '@/mocks/server';
import Application from '@/services/application';
import Instance from '@/services/instance';
import { render } from '@/test-utils';
import TreeGraph from '@/views/instances/sbomdependencytrees/tree.vue';

describe('TreeGraph cyclic dependencies', () => {
beforeEach(() => vi.useFakeTimers({ ignoreMissingTimers: true }));
afterEach(() => vi.useRealTimers());

it('renders terminal cycle references and preserves them when filtering', async () => {
const application = new Application(applications[0]);
const instance = application.instances[0];
server.use(
http.get('/instances/:instanceId/actuator/sbom/application', () =>
HttpResponse.json({
metadata: { component: { 'bom-ref': 'app' } },
dependencies: [
{ ref: 'app', dependsOn: ['a'] },
{ ref: 'a', dependsOn: ['b'] },
{ ref: 'b', dependsOn: ['a'] },
],
}),
),
);
const component = render(TreeGraph, {
props: { instance, sbomId: 'application' },
});

await screen.findAllByText('app');
await fireEvent.click(screen.getAllByText('a')[0]);
await fireEvent.click(screen.getAllByText('b')[0]);
expect(screen.getByText('↩ a')).toBeInTheDocument();
expect(component.container.querySelectorAll('.node-cycle')).toHaveLength(1);
expect(screen.getByText('instances.sbom.legend.cycle')).toBeInTheDocument();

// Clicking a cycle reference must not expand it again.
const nodeCount = component.container.querySelectorAll('rect.node').length;
await fireEvent.click(screen.getByText('↩ a'));
expect(component.container.querySelectorAll('rect.node')).toHaveLength(
nodeCount,
);

await component.rerender({ filter: 'a' });
vi.advanceTimersByTime(2000);
expect(screen.getByText('↩ a')).toBeInTheDocument();
expect(component.container.querySelectorAll('.node-cycle')).toHaveLength(1);

await component.rerender({ filter: 'no-match' });
vi.advanceTimersByTime(2000);
expect(screen.getByTestId('treecontainer-svg')).not.toBeVisible();

await component.rerender({ filter: '' });
vi.advanceTimersByTime(2000);
expect(screen.getByTestId('treecontainer-svg')).toBeVisible();
await fireEvent.click(screen.getAllByText('a')[0]);
await fireEvent.click(screen.getAllByText('b')[0]);
expect(screen.getByText('↩ a')).toBeInTheDocument();
});
});

const setUnknownFilter = async (
dependencyTree: RenderResult,
instance: Instance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
<div class="node-with-children w-3 h-3 rounded-full shadow-sm"></div>
<span>{{ t('instances.sbom.legend.node_with_children') }}</span>
</div>
<div class="flex items-center gap-2">
<span aria-hidden="true">↩</span>
<span>{{ t('instances.sbom.legend.cycle') }}</span>
</div>
</div>
</sba-instance-section>
</template>
Expand Down Expand Up @@ -167,4 +171,9 @@ onBeforeUnmount(() => {
stroke: #cccccc;
stroke-width: 1;
}

:deep(.node-cycle) {
stroke: currentColor;
stroke-dasharray: 4 2;
}
</style>