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
21 changes: 11 additions & 10 deletions docs/design/2026-09-dashboard-plugin-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,15 +698,17 @@ base commit from `GRAPH_RECORD_BASE_REF`, then `origin/main`, and fails rather t
base is reachable; the build workflow checks out full history so the base is present. A record that
does not exist at the base is a new fixture, not a mutated baseline, and needs no entry.

The corpus is authored in `Applications.Core/*` and `Applications.Datastores/*` rather than the
`Radius.*` namespace that `main` now defaults to (#327). That is deliberate and not an oversight.
`AppGraph.tsx` compares against the literal strings `Applications.Core/containers` (line 93, layout
order) and `Applications.Core/gateways` (line 122, the inbound-to-outbound edge workaround). A
`Radius.*` corpus would fall through both branches, so it would approve records that never reach the
code the records exist to characterize — in particular the gateway workaround would go unexercised.
The namespace-insensitivity is itself a product defect, filed as #373. Once the comparisons are
namespace-aware, migrating the corpus becomes a single declared change that also gains a real
assertion, instead of two rename rounds that gain nothing.
The record corpus remains authored in `Applications.Core/*` and `Applications.Datastores/*`.
For #373, `AppGraph.tsx` now matches explicit lists of built-in types without regard to letter case.
Containers are `Applications.Core/containers` and `Radius.Compute/containers`. Gateways are
`Applications.Core/gateways` and `Radius.Compute/routes`, which replaces gateways for ingress.
`Radius.Core` defines no containers or gateways types, and user-defined types with the same names
get no special handling. Unit tests check the initial container row and gateway edge direction for
both type sets, and check that user-defined types do not match. Graph-model tests check gateway
topology before and after layout. The app E2E test returns `Radius.Compute` and `Radius.Data`
resources for the `Radius.Core` application and checks the visible route-to-container edge. These
tests cover both type sets without changing the saved record corpus. Migrating that corpus remains
a separate change.

Known defects are tracked by invariant, not by record field. Each entry in `knownGraphDefects`
carries an `isPresent` predicate that reads the violated invariant — a dangling edge endpoint, a
Expand Down Expand Up @@ -1059,7 +1061,6 @@ its issue is fixed, and that failure is the signal the fix landed, not a regress
| #368 | The graph has no explicit empty state or degraded layout-failure state | GU-15, GU-17 |
| #369 | Partial namespace failures are silently presented as complete inventory | ER-08 |
| #370 | The graph request error state has no retry action | GU-16 |
| #373 | `AppGraph` hard-codes `Applications.Core/*` type literals, so `Radius.Core` resources take different layout and edge-direction paths | Tier C corpus namespace choice; no assertion yet |

Six notes on reading this table.

Expand Down
39 changes: 38 additions & 1 deletion packages/app/e2e-tests/radiusGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ const resourceType = (namespace: string) => ({
APIVersionList: ['2025-01-01'],
});

const radiusTypes: [RegExp, string][] = [
[/Applications\.Core\/containers/g, 'Radius.Compute/containers'],
[/Applications\.Core\/gateways/g, 'Radius.Compute/routes'],
[/Applications\.Datastores\/redisCaches/g, 'Radius.Data/redisCaches'],
];

const graphTypes = (namespace: string) =>
namespace === 'Radius.Core'
? {
container: 'Radius.Compute/containers',
gateway: 'Radius.Compute/routes',
}
: {
container: 'Applications.Core/containers',
gateway: 'Applications.Core/gateways',
};

async function fulfillRadiusRequest(route: Route) {
const url = decodeURIComponent(route.request().url());

Expand All @@ -60,7 +77,13 @@ async function fulfillRadiusRequest(route: Route) {
}

if (url.includes('/getGraph?')) {
await route.fulfill({ json: multiTier });
const graph = url.includes('/Radius.Core/')
? radiusTypes.reduce(
(body, [legacy, current]) => body.replace(legacy, current),
JSON.stringify(multiTier),
)
: JSON.stringify(multiTier);
await route.fulfill({ body: graph, contentType: 'application/json' });
return;
}

Expand Down Expand Up @@ -169,6 +192,20 @@ test.describe('Radius application graph journey', () => {
name: /^Edge from .*backend.* to .*frontend/,
}),
).toBeVisible();
const { container, gateway } = graphTypes(item.type.split('/')[0]);
const providers = '/planes/radius/local/resourceGroups/demo/providers';
await expect(
page.getByRole('button', {
name: `Edge from ${providers}/${gateway}/edge to ${providers}/${container}/frontend`,
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: `Edge from ${providers}/${container}/frontend to ${providers}/${gateway}/edge`,
exact: true,
}),
).toHaveCount(0);

await page.goto('/applications');
}
Expand Down
30 changes: 30 additions & 0 deletions packages/rad-components/src/__test__/graphInvariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,36 @@ describe('graph invariants', () => {
});

describe('edge direction', () => {
it.each([
['Applications.Core/gateways', 'Applications.Core/containers'],
['Radius.Compute/routes', 'Radius.Compute/containers'],
])(
'preserves %s to %s topology through model building and layout',
(gatewayType, containerType) => {
const graph = (): AppGraph =>
JSON.parse(
JSON.stringify(gatewayInbound)
.replace(/Applications\.Core\/gateways/g, gatewayType)
.replace(/Applications\.Core\/containers/g, containerType),
) as AppGraph;
const expected: Relationship[] = [
[
fixtureId(`${gatewayType}/edge`),
fixtureId(`${containerType}/webapp`),
],
];

assertRelationships(buildGraphModel(graph()), expected);
const layouted = buildLayoutedGraphModel(graph());
assertRelationships(layouted, expected);
expect(layouted.nodes).toHaveLength(2);
for (const node of layouted.nodes) {
expect(Number.isFinite(node.position.x)).toBe(true);
expect(Number.isFinite(node.position.y)).toBe(true);
}
},
);

it('orients an outbound connection from the dependency to the dependent', () => {
const model = buildGraphModel(load(containerToDatabase));

Expand Down
20 changes: 18 additions & 2 deletions packages/rad-components/src/components/appgraph/AppGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ function AppGraph(props: AppGraphProps) {
);
}

// Built-in Radius types that get special handling. Both the legacy and the current
// namespaces are listed so that user-defined types with the same name do not match.
const CONTAINER_TYPES = new Set([
'applications.core/containers',
'radius.compute/containers',
]);
// Radius.Compute/routes replaces Applications.Core/gateways for ingress.
const GATEWAY_TYPES = new Set([
'applications.core/gateways',
'radius.compute/routes',
]);

// Radius resource types are case-insensitive.
const isResourceType = (type: string, types: Set<string>) =>
types.has(type.toLowerCase());

export function initialNodes(graph: AppGraphData): {
nodes: Node<Resource>[];
edges: Edge[];
Expand All @@ -90,7 +106,7 @@ export function initialNodes(graph: AppGraphData): {
// This is computed based on the number of inbound connections (or whether it's a container).
const order =
resource.connections?.filter(c => c.direction === 'Inbound').length ||
resource.type === 'Applications.Core/containers'
isResourceType(resource.type, CONTAINER_TYPES)
? 0
: 3;

Expand Down Expand Up @@ -119,7 +135,7 @@ export function initialNodes(graph: AppGraphData): {

if (
connection.direction === 'Inbound' &&
parsedConnection.type === 'Applications.Core/gateways'
isResourceType(parsedConnection.type, GATEWAY_TYPES)
) {
connection.direction = 'Outbound';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,128 @@ import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import Dagre from '@dagrejs/dagre';
import AppGraph from '../AppGraph';
import AppGraph, { initialNodes } from '../AppGraph';
import { AppGraph as AppGraphData, Direction } from '../../../graph';
import * as sampledata from '../../../sampledata';

const resource = (type: string, name: string) => ({
id: `/planes/radius/local/resourceGroups/demo/providers/${type}/${name}`,
name,
type,
provider: 'radius',
provisioningState: 'Succeeded',
});

describe.each([
['Applications.Core/containers', 'Applications.Core/gateways'],
['Radius.Compute/containers', 'Radius.Compute/routes'],
])('%s and %s graph types', (containerType, gatewayType) => {
it('places containers without inbound connections in the leading row', () => {
const graph: AppGraphData = {
name: 'layout',
resources: [
resource(containerType, 'webapp'),
resource(gatewayType, 'edge'),
],
};

const { nodes } = initialNodes(graph);

expect(nodes[0].position).toEqual({ x: 50, y: 0 });
expect(nodes[1].position).toEqual({ x: 50, y: 150 });
});

it.each([
[gatewayType, 'Inbound', true],
[gatewayType, 'Outbound', true],
[containerType, 'Inbound', false],
] as const)(
'orients a %s %s connection',
(type, direction: Direction, dependencyIsSource) => {
const owner = resource(containerType, 'webapp');
const dependency = resource(type, 'dependency');
const graph: AppGraphData = {
name: 'direction',
resources: [
{
...owner,
connections: [{ ...dependency, direction }],
},
dependency,
],
};

const { edges } = initialNodes(graph);

expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: dependencyIsSource ? dependency.id : owner.id,
target: dependencyIsSource ? owner.id : dependency.id,
});
},
);
});

describe('user-defined graph types', () => {
it.each([
'Contoso.Net/gateways',
'Contoso.Net/routes',
'Radius.Networking/gateways',
'Applications.Core/mygateways',
])('does not reverse an Inbound connection to %s', type => {
const owner = resource('Radius.Compute/containers', 'webapp');
const dependency = resource(type, 'dependency');
const graph: AppGraphData = {
name: 'direction',
resources: [
{ ...owner, connections: [{ ...dependency, direction: 'Inbound' }] },
dependency,
],
};

const { edges } = initialNodes(graph);

expect(edges).toEqual([
expect.objectContaining({ source: owner.id, target: dependency.id }),
]);
});

it.each(['Contoso.Net/containers', 'Radius.Networking/containers'])(
'does not lead the layout with %s',
type => {
const { nodes } = initialNodes({
name: 'layout',
resources: [resource(type, 'worker')],
});

expect(nodes[0].position).toEqual({ x: 50, y: 150 });
},
);

it('matches built-in types without regard to letter case', () => {
const owner = resource('radius.compute/containers', 'webapp');
const dependency = resource('radius.compute/routes', 'edge');
const { nodes, edges } = initialNodes({
name: 'case',
resources: [
{ ...owner, connections: [{ ...dependency, direction: 'Inbound' }] },
dependency,
],
});

expect(nodes[1].position).toEqual({ x: 50, y: 150 });
expect(edges).toEqual([
expect.objectContaining({ source: dependency.id, target: owner.id }),
]);

const lone = initialNodes({
name: 'case',
resources: [resource('RADIUS.COMPUTE/CONTAINERS', 'lone')],
});
expect(lone.nodes[0].position).toEqual({ x: 50, y: 0 });
});
});

describe('AppGraph component', () => {
// GU-17 replaces Dagre.layout on the shared module object. Without this the
// throwing implementation survives into every later test in this file.
Expand Down