core-plugin-api: add forwards compatibility for route refs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-17 21:19:03 +01:00
parent 4d03f08d19
commit 83439b1539
22 changed files with 496 additions and 108 deletions
@@ -22,7 +22,12 @@ import {
RouteRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import {
BackstagePlugin,
createRouteRef as createLegacyRouteRef,
createSubRouteRef as createLegacySubRouteRef,
createExternalRouteRef as createLegacyExternalRouteRef,
} from '@backstage/core-plugin-api';
import { RouteResolver } from './RouteResolver';
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
import {
@@ -416,4 +421,196 @@ describe('RouteResolver', () => {
'/my-parent/a%2F%23%26%3Fb',
);
});
describe('with legacy route refs', () => {
const legacyRef1 = createLegacyRouteRef({ id: 'ref1' });
const legacyRef2 = createLegacyRouteRef({ id: 'ref2', params: ['x'] });
const legacyRef3 = createLegacyRouteRef({ id: 'ref3', params: ['y'] });
const legacySubRef1 = createLegacySubRouteRef({
id: 'sub1',
parent: legacyRef1,
path: '/foo',
});
const legacySubRef2 = createLegacySubRouteRef({
id: 'sub2',
parent: legacyRef1,
path: '/foo/:a',
});
const legacySubRef3 = createLegacySubRouteRef({
id: 'sub3',
parent: legacyRef2,
path: '/bar',
});
const legacySubRef4 = createLegacySubRouteRef({
id: 'sub4',
parent: legacyRef2,
path: '/bar/:a',
});
const legacyExternalRef1 = createLegacyExternalRouteRef({
id: 'external1',
});
const legacyExternalRef2 = createLegacyExternalRouteRef({
id: 'external2',
params: ['x'],
});
it('should not resolve anything with an empty resolver', () => {
const r = new RouteResolver(
new Map(),
new Map(),
[],
new Map(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(legacyRef1, src('/'))?.()).toBe(undefined);
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(undefined);
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe(undefined);
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(undefined);
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
undefined,
);
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe(undefined);
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '5x' })).toBe(
undefined,
);
});
it('should resolve an absolute route', () => {
const r = new RouteResolver(
new Map([[legacyRef1, 'my-route']]),
new Map(),
[{ routeRefs: new Set([legacyRef1]), path: 'my-route', ...rest }],
new Map(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(legacyRef1, src('/'))?.()).toBe('/my-route');
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(undefined);
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe('/my-route/foo');
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(
'/my-route/foo/2a',
);
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
undefined,
);
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe(undefined);
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '5x' })).toBe(
undefined,
);
});
it('should resolve an absolute route with a param and with a parent', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[legacyRef1, 'my-route'],
[legacyRef2, 'my-parent/:x'],
]),
new Map([[legacyRef2, legacyRef1]]),
[
{
routeRefs: new Set([legacyRef2]),
path: 'my-parent/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{ routeRefs: new Set([legacyRef1]), path: 'my-route', ...rest },
],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
[legacyExternalRef1, legacyRef1],
[legacyExternalRef2, legacySubRef3],
]),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(legacyRef1, src('/'))?.()).toBe('/my-route');
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(
'/my-route/my-parent/1x',
);
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe('/my-route/foo');
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(
'/my-route/foo/2a',
);
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(
'/my-route/my-parent/3x/bar',
);
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
'/my-route/my-parent/4x/bar/4a',
);
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe('/my-route');
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '6x' })).toBe(
'/my-route/my-parent/6x/bar',
);
});
it('should resolve the most specific match', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[legacyRef1, 'deep'],
[legacyRef2, 'root/:x'],
[legacyRef3, 'sub/:y'],
]),
new Map<RouteRef, RouteRef>([
[legacyRef3, legacyRef2],
[legacyRef1, legacyRef3],
]),
[
{
routeRefs: new Set([legacyRef2]),
path: 'root/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{
routeRefs: new Set([legacyRef3]),
path: 'sub/:y',
...rest,
children: [
MATCH_ALL_ROUTE,
{
routeRefs: new Set([legacyRef1]),
path: 'deep',
...rest,
},
],
},
],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(legacyRef2, src('/'))?.({ x: 'x' })).toBe('/root/x');
expect(r.resolve(legacyRef3, src('/root/x'))?.({ y: 'y' })).toBe(
'/root/x/sub/y',
);
expect(() => r.resolve(legacyRef1, src('/'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(() => r.resolve(legacyRef1, src('/root/x'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(legacyRef1, src('/root/x/sub/y'))?.()).toBe(
'/root/x/sub/y/deep',
);
// Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here
expect(
r.resolve(legacyRef1, src('/root/x/sub/y/any/nested/path/here'))?.(),
).toBe('/root/x/sub/y/deep');
});
});
});
@@ -39,10 +39,10 @@ describe('collectRouteIds', () => {
const extRef = createExternalRouteRef();
expect(String(ref)).toMatch(
/^RouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
/^routeRef\{id=undefined,at='.*collectRouteIds\.test\.ts.*'\}$/,
);
expect(String(extRef)).toMatch(
/^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
/^externalRouteRef\{id=undefined,at='.*collectRouteIds\.test\.ts.*'\}$/,
);
const collected = collectRouteIds(
@@ -62,8 +62,12 @@ describe('collectRouteIds', () => {
'test.extRef': extRef,
});
expect(String(ref)).toBe('RouteRef{test.ref}');
expect(String(extRef)).toBe('ExternalRouteRef{test.extRef}');
expect(String(ref)).toMatch(
/^routeRef\{id=test.ref,at='.*collectRouteIds\.test\.ts.*'\}$/,
);
expect(String(extRef)).toMatch(
/^externalRouteRef\{id=test.extRef,at='.*collectRouteIds\.test\.ts.*'\}$/,
);
});
it('should report duplicate route IDs', () => {
@@ -636,7 +636,7 @@ describe('discovery', () => {
},
),
).toThrow(
/Refused to resolve alias 'other.root' for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/,
/Refused to resolve alias 'other.root' for routeRef{id=undefined,at='.*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/,
);
});
@@ -662,7 +662,7 @@ describe('discovery', () => {
},
),
).toThrow(
/Alias loop detected for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/,
/Alias loop detected for routeRef{id=undefined,at='.*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/,
);
});
});