Skip to content

Commit b242cc5

Browse files
fix(boost): address PR review feedback on AI Catalog scaffold
- Simplify useAiAssets hook: single fetch on mount, all filtering via useMemo (no unnecessary refetches on client-side filter changes) - Align isAiAsset and buildCatalogFilter: both use shared AI_ASSET_SPEC_TYPES, AiResource without spec.type returns false - Use Entity type from @backstage/catalog-model in isAiAsset - Add comprehensive useAiAssets hook unit tests (6 cases including no-refetch verification) - Update isAiAsset tests for new behavior and spec.type case-insensitivity - Fix stale boost-frontend reference in AGENTS.md Signed-off-by: Rohit Rai <rohitkrai03@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2d72a7e commit b242cc5

5 files changed

Lines changed: 325 additions & 140 deletions

File tree

workspaces/boost/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Agents, tools, models, MCP servers, and vector stores are Backstage catalog enti
7272

7373
| Package | Purpose |
7474
| --------------------------------- | -------------------------------------------------------------------- |
75-
| `boost-frontend` | Chat UI, agent gallery, admin panels, composable routable extensions |
75+
| `boost` | Chat UI, agent gallery, admin panels, composable routable extensions |
7676
| `boost-common` | Shared types, permissions (browser-safe, `common-library` role) |
7777
| `boost-node` | `boostAiProviderServiceRef`, extension points (`node-library` role) |
7878
| `boost-backend` | Core routes, services, middleware, ProviderManager |
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { type Entity } from '@backstage/catalog-model';
18+
import {
19+
type CatalogApi,
20+
catalogApiRef,
21+
} from '@backstage/plugin-catalog-react';
22+
import { TestApiProvider } from '@backstage/test-utils';
23+
import { renderHook, waitFor } from '@testing-library/react';
24+
import { type ReactNode, createElement } from 'react';
25+
26+
import { useAiAssets } from './useAiAssets';
27+
28+
const aiSkill: Entity = {
29+
apiVersion: 'backstage.io/v1alpha1',
30+
kind: 'AiResource',
31+
metadata: {
32+
name: 'test-skill',
33+
namespace: 'default',
34+
description: 'A test skill for code review',
35+
tags: ['security', 'quality'],
36+
},
37+
spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai' },
38+
};
39+
40+
const aiAgent: Entity = {
41+
apiVersion: 'backstage.io/v1alpha1',
42+
kind: 'Component',
43+
metadata: {
44+
name: 'test-agent',
45+
namespace: 'default',
46+
description: 'A test agent',
47+
tags: ['agent'],
48+
},
49+
spec: { type: 'ai-agent', lifecycle: 'experimental', owner: 'team-ml' },
50+
};
51+
52+
const nonAiComponent: Entity = {
53+
apiVersion: 'backstage.io/v1alpha1',
54+
kind: 'Component',
55+
metadata: { name: 'web-service', namespace: 'default', tags: [] },
56+
spec: { type: 'service', lifecycle: 'production', owner: 'team-web' },
57+
};
58+
59+
const mockCatalogApi: Pick<jest.Mocked<CatalogApi>, 'getEntities'> = {
60+
getEntities: jest.fn(),
61+
};
62+
63+
function wrapper({ children }: { children: ReactNode }) {
64+
return createElement(
65+
TestApiProvider,
66+
{
67+
apis: [[catalogApiRef, mockCatalogApi as unknown as CatalogApi]],
68+
children,
69+
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
70+
);
71+
}
72+
73+
describe('useAiAssets', () => {
74+
beforeEach(() => {
75+
mockCatalogApi.getEntities.mockReset();
76+
});
77+
78+
it('returns loading true initially', () => {
79+
mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {}));
80+
const { result } = renderHook(() => useAiAssets(), { wrapper });
81+
expect(result.current.loading).toBe(true);
82+
expect(result.current.entities).toEqual([]);
83+
});
84+
85+
it('fetches and filters AI asset entities', async () => {
86+
mockCatalogApi.getEntities.mockResolvedValue({
87+
items: [aiSkill, aiAgent, nonAiComponent],
88+
});
89+
90+
const { result } = renderHook(() => useAiAssets(), { wrapper });
91+
92+
await waitFor(() => {
93+
expect(result.current.loading).toBe(false);
94+
});
95+
96+
expect(result.current.entities).toHaveLength(2);
97+
expect(result.current.entities.map(e => e.metadata.name)).toEqual([
98+
'test-skill',
99+
'test-agent',
100+
]);
101+
});
102+
103+
it('filters by search term (name, description, tags)', async () => {
104+
mockCatalogApi.getEntities.mockResolvedValue({
105+
items: [aiSkill, aiAgent],
106+
});
107+
108+
const { result } = renderHook(
109+
() => useAiAssets({ search: 'code review' }),
110+
{ wrapper },
111+
);
112+
113+
await waitFor(() => {
114+
expect(result.current.loading).toBe(false);
115+
});
116+
117+
expect(result.current.entities).toHaveLength(1);
118+
expect(result.current.entities[0].metadata.name).toBe('test-skill');
119+
});
120+
121+
it('filters by tags', async () => {
122+
mockCatalogApi.getEntities.mockResolvedValue({
123+
items: [aiSkill, aiAgent],
124+
});
125+
126+
const { result } = renderHook(() => useAiAssets({ tags: ['agent'] }), {
127+
wrapper,
128+
});
129+
130+
await waitFor(() => {
131+
expect(result.current.loading).toBe(false);
132+
});
133+
134+
expect(result.current.entities).toHaveLength(1);
135+
expect(result.current.entities[0].metadata.name).toBe('test-agent');
136+
});
137+
138+
it('filters by category (spec.type)', async () => {
139+
mockCatalogApi.getEntities.mockResolvedValue({
140+
items: [aiSkill, aiAgent],
141+
});
142+
143+
const { result } = renderHook(() => useAiAssets({ category: ['skill'] }), {
144+
wrapper,
145+
});
146+
147+
await waitFor(() => {
148+
expect(result.current.loading).toBe(false);
149+
});
150+
151+
expect(result.current.entities).toHaveLength(1);
152+
expect(result.current.entities[0].metadata.name).toBe('test-skill');
153+
});
154+
155+
it('sets error on fetch failure', async () => {
156+
mockCatalogApi.getEntities.mockRejectedValue(new Error('Network error'));
157+
158+
const { result } = renderHook(() => useAiAssets(), { wrapper });
159+
160+
await waitFor(() => {
161+
expect(result.current.loading).toBe(false);
162+
});
163+
164+
expect(result.current.error).toBeDefined();
165+
expect(result.current.error?.message).toBe('Network error');
166+
expect(result.current.entities).toEqual([]);
167+
});
168+
169+
it('does not refetch when only client-side filters change', async () => {
170+
mockCatalogApi.getEntities.mockResolvedValue({
171+
items: [aiSkill, aiAgent],
172+
});
173+
174+
const { result, rerender } = renderHook(
175+
({ filters }) => useAiAssets(filters),
176+
{ wrapper, initialProps: { filters: {} } },
177+
);
178+
179+
await waitFor(() => {
180+
expect(result.current.loading).toBe(false);
181+
});
182+
183+
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
184+
185+
rerender({ filters: { search: 'skill' } });
186+
187+
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
188+
expect(result.current.entities).toHaveLength(1);
189+
});
190+
});

0 commit comments

Comments
 (0)