Skip to content

Commit f3f900e

Browse files
authored
feat(x2a): introduce polling to keep the views up-to-date (#2561)
* feat(x2a): introduce polling to keep the views up-to-date Signed-off-by: Marek Libra <marek.libra@gmail.com> * Upgrade @testing-library/react to be able to use act() without warnings * Performance and maintainability fixes Centralize polling for DetailPanels. Delay polling in case of unreachable server to prevent DOS (do not obverload). Safer handling of usePolledFetch dependencies. Signed-off-by: Marek Libra <marek.libra@gmail.com> --------- Signed-off-by: Marek Libra <marek.libra@gmail.com>
1 parent b2dcec0 commit f3f900e

21 files changed

Lines changed: 2227 additions & 161 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-x2a-common': patch
3+
'@red-hat-developer-hub/backstage-plugin-x2a': patch
4+
---
5+
6+
feat(x2a): introduce polling to keep the views up-to-date (affects multiple pages)

workspaces/x2a/plugins/x2a-common/report.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ export interface Job {
133133
// @public (undocumented)
134134
export type JobStatusEnum = 'pending' | 'running' | 'success' | 'error' | 'cancelled';
135135

136+
// @public
137+
export const MAX_BACKOFF_MS: number;
138+
136139
// @public
137140
export const MAX_CONCURRENT_BULK_RUN = 3;
138141

@@ -176,6 +179,9 @@ export type ModuleStatus = 'pending' | 'running' | 'success' | 'error' | 'cancel
176179
// @public
177180
export function normalizeRepoUrl(url: string): string;
178181

182+
// @public
183+
export const POLLING_INTERVAL_MS: number;
184+
179185
// @public (undocumented)
180186
export interface Project {
181187
abbreviation: string;

workspaces/x2a/plugins/x2a-common/src/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,17 @@ export const CREATE_CHEF_PROJECT_TEMPLATE_PATH =
4949
* @public
5050
*/
5151
export const MAX_CONCURRENT_BULK_RUN = 3;
52+
53+
/**
54+
* Polling interval for refreshing data views.
55+
*
56+
* @public
57+
*/
58+
export const POLLING_INTERVAL_MS = 10 * 1000;
59+
60+
/**
61+
* Limit of increasing of the polling interval on consecutive errors (like server not reachable).
62+
*
63+
* @public
64+
*/
65+
export const MAX_BACKOFF_MS = 5 * 60 * 1000;

workspaces/x2a/plugins/x2a/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@mui/system": "^5.15.14",
5050
"@mui/x-charts": "^7.0.0",
5151
"@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*",
52+
"fast-deep-equal": "^3.1.3",
5253
"react-use": "^17.2.4"
5354
},
5455
"peerDependencies": {
@@ -63,7 +64,7 @@
6364
"@backstage/frontend-plugin-api": "^0.14.1",
6465
"@backstage/test-utils": "^1.7.11",
6566
"@testing-library/jest-dom": "^6.0.0",
66-
"@testing-library/react": "^14.0.0",
67+
"@testing-library/react": "^16.0.0",
6768
"@testing-library/user-event": "^14.0.0",
6869
"msw": "^1.0.0",
6970
"react": "^18.0.0",
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
import { mockUseTranslation } from '../../test-utils/mockTranslations';
17+
18+
jest.mock('../../hooks/useTranslation', () => ({
19+
useTranslation: mockUseTranslation,
20+
}));
21+
22+
const mockProjectGet = jest.fn();
23+
const mockModuleGet = jest.fn();
24+
const clientServiceMock = {
25+
projectsProjectIdGet: mockProjectGet,
26+
projectsProjectIdModulesModuleIdGet: mockModuleGet,
27+
projectsProjectIdModulesModuleIdRunPost: jest.fn(),
28+
projectsProjectIdModulesModuleIdCancelPost: jest.fn(),
29+
};
30+
jest.mock('../../ClientService', () => ({
31+
useClientService: () => clientServiceMock,
32+
}));
33+
34+
jest.mock('@backstage/core-plugin-api', () => ({
35+
...jest.requireActual('@backstage/core-plugin-api'),
36+
useRouteRefParams: () => ({ projectId: 'proj-1', moduleId: 'mod-1' }),
37+
}));
38+
39+
jest.mock('../../hooks/useScmHostMap', () => ({
40+
useScmHostMap: () => ({}),
41+
}));
42+
43+
jest.mock('../../repoAuth', () => ({
44+
useRepoAuthentication: () => ({
45+
authenticate: jest.fn().mockResolvedValue([{ token: 'mock-token' }]),
46+
}),
47+
}));
48+
49+
jest.mock('./ModulePageBreadcrumb', () => ({
50+
ModulePageBreadcrumb: () => <div data-testid="breadcrumb" />,
51+
}));
52+
53+
jest.mock('./ArtifactsCard', () => ({
54+
ArtifactsCard: () => <div data-testid="artifacts-card" />,
55+
}));
56+
57+
jest.mock('./ModuleDetailsCard', () => ({
58+
ModuleDetailsCard: ({ module }: { module?: { name: string } }) => (
59+
<div data-testid="module-details">{module?.name}</div>
60+
),
61+
}));
62+
63+
jest.mock('./PhasesCard', () => ({
64+
PhasesCard: ({
65+
project,
66+
module,
67+
}: {
68+
project?: { name: string };
69+
module?: { name: string };
70+
}) => (
71+
<div data-testid="phases-card">
72+
{project?.name} / {module?.name}
73+
</div>
74+
),
75+
}));
76+
77+
jest.mock('@backstage/core-components', () => ({
78+
Content: ({ children }: { children: React.ReactNode }) => (
79+
<div>{children}</div>
80+
),
81+
Header: ({ children }: { children: React.ReactNode }) => (
82+
<div>{children}</div>
83+
),
84+
Page: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
85+
Progress: () => <div role="progressbar" />,
86+
ResponseErrorPanel: ({ error }: { error: Error }) => (
87+
<div role="alert">{error.message}</div>
88+
),
89+
}));
90+
91+
import { render, screen, waitFor, act } from '@testing-library/react';
92+
import { POLLING_INTERVAL_MS } from '@red-hat-developer-hub/backstage-plugin-x2a-common';
93+
import { ModulePage } from './ModulePage';
94+
95+
const mockProject = {
96+
id: 'proj-1',
97+
name: 'Test Project',
98+
abbreviation: 'TP',
99+
description: 'Test',
100+
sourceRepoUrl: 'https://github.com/org/source',
101+
targetRepoUrl: 'https://github.com/org/target',
102+
sourceRepoBranch: 'main',
103+
targetRepoBranch: 'main',
104+
createdAt: new Date('2024-01-01T00:00:00Z'),
105+
createdBy: 'user:default/test',
106+
};
107+
108+
const mockModule = {
109+
id: 'mod-1',
110+
name: 'module-a',
111+
projectId: 'proj-1',
112+
};
113+
114+
describe('ModulePage', () => {
115+
beforeEach(() => {
116+
jest.useFakeTimers();
117+
mockProjectGet.mockReset();
118+
mockModuleGet.mockReset();
119+
});
120+
121+
afterEach(() => {
122+
jest.useRealTimers();
123+
});
124+
125+
it('shows loading indicator while fetching', () => {
126+
mockProjectGet.mockReturnValue(new Promise(() => {}));
127+
mockModuleGet.mockReturnValue(new Promise(() => {}));
128+
129+
render(<ModulePage />);
130+
131+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
132+
});
133+
134+
it('renders module data after successful fetch', async () => {
135+
mockProjectGet.mockResolvedValue({ json: async () => mockProject });
136+
mockModuleGet.mockResolvedValue({ json: async () => mockModule });
137+
138+
render(<ModulePage />);
139+
140+
await waitFor(() => {
141+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
142+
});
143+
144+
expect(screen.getByText('module-a')).toBeInTheDocument();
145+
expect(screen.getByText('Test Project / module-a')).toBeInTheDocument();
146+
});
147+
148+
it('shows error panel when fetch fails', async () => {
149+
mockProjectGet.mockRejectedValue(new Error('API failure'));
150+
mockModuleGet.mockResolvedValue({ json: async () => mockModule });
151+
152+
render(<ModulePage />);
153+
154+
await waitFor(() => {
155+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
156+
});
157+
158+
expect(screen.getByText(/API failure/)).toBeInTheDocument();
159+
});
160+
161+
it('polls for data after POLLING_INTERVAL_MS', async () => {
162+
mockProjectGet.mockResolvedValue({ json: async () => mockProject });
163+
mockModuleGet.mockResolvedValue({ json: async () => mockModule });
164+
165+
render(<ModulePage />);
166+
167+
await waitFor(() => {
168+
expect(screen.getByText('module-a')).toBeInTheDocument();
169+
});
170+
171+
const initialProjectCalls = mockProjectGet.mock.calls.length;
172+
const initialModuleCalls = mockModuleGet.mock.calls.length;
173+
174+
await act(async () => {
175+
jest.advanceTimersByTime(POLLING_INTERVAL_MS);
176+
});
177+
178+
await waitFor(() => {
179+
expect(mockProjectGet.mock.calls.length).toBeGreaterThan(
180+
initialProjectCalls,
181+
);
182+
expect(mockModuleGet.mock.calls.length).toBeGreaterThan(
183+
initialModuleCalls,
184+
);
185+
});
186+
});
187+
188+
it('does not show loading indicator during polling refresh', async () => {
189+
mockProjectGet.mockResolvedValue({ json: async () => mockProject });
190+
mockModuleGet.mockResolvedValue({ json: async () => mockModule });
191+
192+
render(<ModulePage />);
193+
194+
await waitFor(() => {
195+
expect(screen.getByText('module-a')).toBeInTheDocument();
196+
});
197+
198+
await act(async () => {
199+
jest.advanceTimersByTime(POLLING_INTERVAL_MS);
200+
});
201+
202+
await waitFor(() => {
203+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
204+
});
205+
expect(screen.getByText('module-a')).toBeInTheDocument();
206+
});
207+
208+
it('fetches project and module in parallel', async () => {
209+
let projectResolve: (v: any) => void;
210+
let moduleResolve: (v: any) => void;
211+
212+
mockProjectGet.mockReturnValue(
213+
new Promise(r => {
214+
projectResolve = r;
215+
}),
216+
);
217+
mockModuleGet.mockReturnValue(
218+
new Promise(r => {
219+
moduleResolve = r;
220+
}),
221+
);
222+
223+
render(<ModulePage />);
224+
225+
expect(mockProjectGet).toHaveBeenCalledTimes(1);
226+
expect(mockModuleGet).toHaveBeenCalledTimes(1);
227+
228+
projectResolve!({ json: async () => mockProject });
229+
moduleResolve!({ json: async () => mockModule });
230+
231+
await waitFor(() => {
232+
expect(screen.getByText('module-a')).toBeInTheDocument();
233+
});
234+
});
235+
236+
it('recovers from error on next successful poll', async () => {
237+
mockProjectGet.mockRejectedValueOnce(new Error('Temporary failure'));
238+
mockModuleGet.mockResolvedValue({ json: async () => mockModule });
239+
240+
render(<ModulePage />);
241+
242+
await waitFor(() => {
243+
expect(screen.getByText(/Temporary failure/)).toBeInTheDocument();
244+
});
245+
246+
mockProjectGet.mockResolvedValue({ json: async () => mockProject });
247+
248+
// After first error, backoff = POLLING_INTERVAL_MS * 2
249+
await act(async () => {
250+
jest.advanceTimersByTime(POLLING_INTERVAL_MS * 2);
251+
});
252+
253+
await waitFor(() => {
254+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
255+
});
256+
expect(screen.getByText('module-a')).toBeInTheDocument();
257+
});
258+
259+
it('does not update state after unmount', async () => {
260+
let projectResolve: (v: any) => void;
261+
mockProjectGet.mockReturnValue(
262+
new Promise(r => {
263+
projectResolve = r;
264+
}),
265+
);
266+
mockModuleGet.mockReturnValue(new Promise(() => {}));
267+
268+
const { unmount } = render(<ModulePage />);
269+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
270+
271+
unmount();
272+
273+
// Resolve after unmount – should not throw
274+
projectResolve!({ json: async () => mockProject });
275+
});
276+
});

0 commit comments

Comments
 (0)