Skip to content

Commit f6f6d3d

Browse files
committed
fix: keep the faults dashboard in place while the fault list refreshes
An empty fault list was indistinguishable from a list that had never loaded: fetchFaults derived "initial load" from faults.length === 0, so every later fetch re-entered the loading state and the dashboard swapped itself for its first-load skeleton. The list now carries a faultsLoaded flag, set once an answer arrives - including an error answer, so a retry does not bring the skeleton back either. Refreshing is also shared now. The dashboard and the sidebar badge each ran their own timer, their own mount fetch and, in the badge, their own visibilitychange listener, so a gateway serving both views answered two requests per refresh. useFaultPolling keeps one timer, one listener and one initial fetch for however many fault views are mounted, and drops the timer while the SSE fault stream is delivering updates.
1 parent 93ef2a8 commit f6f6d3d

6 files changed

Lines changed: 421 additions & 82 deletions

File tree

e2e/faults-refresh.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2026 bburda
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/**
16+
* The fault dashboard against a real gateway that reports no faults - the case
17+
* where "loaded" and "empty" look alike. A refresh must leave the page as it is
18+
* and must cost one request, however many fault views are on screen.
19+
*/
20+
21+
import { test, expect, type Page } from '@playwright/test';
22+
23+
/**
24+
* Reports whether the first-load skeleton appears at any point from now on,
25+
* including for a single frame that polling a locator would step over.
26+
*/
27+
async function watchForSkeleton(page: Page): Promise<() => Promise<boolean>> {
28+
await page.evaluate(() => {
29+
const w = window as unknown as { __skeletonSeen?: boolean; __skeletonObserver?: MutationObserver };
30+
w.__skeletonSeen = false;
31+
const observer = new MutationObserver(() => {
32+
if (document.querySelector('main .animate-pulse')) {
33+
w.__skeletonSeen = true;
34+
}
35+
});
36+
observer.observe(document.body, { childList: true, subtree: true });
37+
w.__skeletonObserver = observer;
38+
});
39+
40+
return async () =>
41+
page.evaluate(() => {
42+
const w = window as unknown as { __skeletonSeen?: boolean; __skeletonObserver?: MutationObserver };
43+
w.__skeletonObserver?.disconnect();
44+
return w.__skeletonSeen === true;
45+
});
46+
}
47+
48+
/** Opens the dashboard and waits for the gateway's (empty) fault list to be on screen. */
49+
async function openDashboard(page: Page): Promise<void> {
50+
await page.goto('/');
51+
await page.getByRole('button', { name: 'Faults Dashboard' }).click();
52+
await expect(page.getByText('No faults to display')).toBeVisible({ timeout: 30_000 });
53+
await expect(page.getByText('No faults detected')).toBeVisible();
54+
}
55+
56+
const POLL_INTERVAL_MS = 5_000;
57+
const OBSERVED_WINDOW_MS = 11_000;
58+
59+
test.describe('faults dashboard refresh', () => {
60+
test('a refresh of an empty fault list leaves the page as it is', async ({ page }) => {
61+
await openDashboard(page);
62+
const skeletonSeen = await watchForSkeleton(page);
63+
64+
// The refresh a tab regaining focus triggers.
65+
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
66+
await page.waitForTimeout(2_000);
67+
68+
expect(await skeletonSeen()).toBe(false);
69+
await expect(page.getByText('No faults to display')).toBeVisible();
70+
});
71+
72+
test('the fallback poll asks once per interval, not once per mounted view', async ({ page }) => {
73+
// With no fault stream the app falls back to polling. The dashboard and the
74+
// sidebar badge are both on screen and read the same list. An HTTP status is
75+
// what makes the client give up at once - a dropped connection it retries.
76+
await page.route('**/faults/stream**', (route) =>
77+
route.fulfill({ status: 404, contentType: 'application/json', body: '{}' })
78+
);
79+
await openDashboard(page);
80+
81+
const faultRequests: string[] = [];
82+
page.on('request', (request) => {
83+
if (new URL(request.url()).pathname.endsWith('/faults')) {
84+
faultRequests.push(request.url());
85+
}
86+
});
87+
await page.waitForTimeout(OBSERVED_WINDOW_MS);
88+
89+
const intervals = Math.floor(OBSERVED_WINDOW_MS / POLL_INTERVAL_MS);
90+
expect(faultRequests.length).toBeGreaterThanOrEqual(intervals);
91+
expect(faultRequests.length).toBeLessThanOrEqual(intervals + 1);
92+
});
93+
});

playwright.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,8 @@ export default defineConfig({
5454
// it needs a fault manager the scripts gateway does not run. Serial for
5555
// the same reason as scripts-serial: one shared gateway.
5656
{ name: 'rosbag-serial', testMatch: /rosbag-.*\.spec\.ts/, fullyParallel: false, workers: 1 },
57+
// Reads the scripts gateway without changing it, but counts the requests the
58+
// app makes, so it must not share a run with specs driving the same app.
59+
{ name: 'faults-serial', testMatch: /faults-.*\.spec\.ts/, fullyParallel: false, workers: 1 },
5760
],
5861
});
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2026 bburda
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/**
16+
* The dashboard and the sidebar badge read the same fault list. These tests pin
17+
* what the user sees while that list refreshes: the page must not fall back to
18+
* its first-load skeleton, and the two views must not each ask the gateway
19+
* separately.
20+
*/
21+
22+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23+
import { render, act } from '@testing-library/react';
24+
import { FaultsDashboard, FaultsCountBadge } from './FaultsDashboard';
25+
import { useAppStore } from '@/lib/store';
26+
27+
const POLL_INTERVAL_MS = 5000;
28+
29+
const RAW_FAULT = {
30+
fault_code: 'LIDAR_RANGE_INVALID',
31+
description: 'range invalid',
32+
severity: 2,
33+
severity_label: 'ERROR',
34+
status: 'CONFIRMED',
35+
first_occurred: 1756636800,
36+
last_occurred: 1756636800,
37+
occurrence_count: 1,
38+
reporting_sources: ['/lidar_driver'],
39+
};
40+
41+
function clientReturning(items: unknown[]) {
42+
return {
43+
GET: vi.fn(async () => {
44+
await Promise.resolve();
45+
return { data: { items }, error: undefined };
46+
}),
47+
};
48+
}
49+
50+
/** Records every DOM state the user could have seen while `run` executed. */
51+
async function framesDuring(container: HTMLElement, run: () => Promise<void>): Promise<string[]> {
52+
const frames: string[] = [];
53+
const record = () => frames.push(container.querySelectorAll('.animate-pulse').length > 0 ? 'skeleton' : 'content');
54+
const observer = new MutationObserver(record);
55+
observer.observe(container, { childList: true, subtree: true });
56+
await run();
57+
if (observer.takeRecords().length > 0) {
58+
record();
59+
}
60+
observer.disconnect();
61+
return frames;
62+
}
63+
64+
async function settle() {
65+
await act(async () => {
66+
await Promise.resolve();
67+
await Promise.resolve();
68+
await Promise.resolve();
69+
});
70+
}
71+
72+
async function advance(ms: number) {
73+
await act(async () => {
74+
vi.advanceTimersByTime(ms);
75+
await Promise.resolve();
76+
});
77+
await settle();
78+
}
79+
80+
function connect(client: ReturnType<typeof clientReturning>, sseActive: boolean) {
81+
useAppStore.setState({
82+
isConnected: true,
83+
client,
84+
faults: [],
85+
isLoadingFaults: false,
86+
faultsLoaded: false,
87+
faultStreamCleanup: sseActive ? () => {} : null,
88+
} as never);
89+
}
90+
91+
beforeEach(() => {
92+
vi.useFakeTimers({ shouldAdvanceTime: true });
93+
});
94+
95+
afterEach(() => {
96+
vi.useRealTimers();
97+
useAppStore.setState({ isConnected: false, client: null, faults: [], faultStreamCleanup: null } as never);
98+
});
99+
100+
describe('FaultsDashboard refresh behaviour', () => {
101+
it('never falls back to the first-load skeleton once the empty list has loaded', async () => {
102+
connect(clientReturning([]), false);
103+
const { container } = render(<FaultsDashboard />);
104+
await settle();
105+
106+
const frames = await framesDuring(container, () => advance(POLL_INTERVAL_MS));
107+
108+
expect(frames).not.toContain('skeleton');
109+
});
110+
111+
it('never falls back to the skeleton when the tab regains focus', async () => {
112+
connect(clientReturning([]), true);
113+
const { container } = render(
114+
<>
115+
<FaultsCountBadge />
116+
<FaultsDashboard />
117+
</>
118+
);
119+
await settle();
120+
121+
const frames = await framesDuring(container, async () => {
122+
await act(async () => {
123+
document.dispatchEvent(new Event('visibilitychange'));
124+
await Promise.resolve();
125+
});
126+
await settle();
127+
});
128+
129+
expect(frames).not.toContain('skeleton');
130+
});
131+
132+
it('still shows the skeleton for the very first load', async () => {
133+
connect(clientReturning([RAW_FAULT]), false);
134+
const { container } = render(<FaultsDashboard />);
135+
136+
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
137+
138+
await settle();
139+
expect(container.querySelectorAll('.animate-pulse').length).toBe(0);
140+
});
141+
142+
it('asks the gateway once per interval even with the badge and the dashboard mounted', async () => {
143+
const client = clientReturning([RAW_FAULT]);
144+
connect(client, false);
145+
render(
146+
<>
147+
<FaultsCountBadge />
148+
<FaultsDashboard />
149+
</>
150+
);
151+
await settle();
152+
expect(client.GET).toHaveBeenCalledTimes(1);
153+
154+
await advance(POLL_INTERVAL_MS);
155+
expect(client.GET).toHaveBeenCalledTimes(2);
156+
157+
await advance(POLL_INTERVAL_MS * 2);
158+
expect(client.GET).toHaveBeenCalledTimes(4);
159+
});
160+
161+
it('does not poll while the fault stream delivers updates', async () => {
162+
const client = clientReturning([RAW_FAULT]);
163+
connect(client, true);
164+
render(
165+
<>
166+
<FaultsCountBadge />
167+
<FaultsDashboard />
168+
</>
169+
);
170+
await settle();
171+
const afterMount = client.GET.mock.calls.length;
172+
173+
await advance(POLL_INTERVAL_MS * 3);
174+
175+
expect(client.GET.mock.calls.length).toBe(afterMount);
176+
});
177+
178+
it('stops polling when the last fault view unmounts', async () => {
179+
const client = clientReturning([RAW_FAULT]);
180+
connect(client, false);
181+
const { unmount } = render(<FaultsDashboard />);
182+
await settle();
183+
const afterMount = client.GET.mock.calls.length;
184+
185+
unmount();
186+
await advance(POLL_INTERVAL_MS * 2);
187+
188+
expect(client.GET.mock.calls.length).toBe(afterMount);
189+
});
190+
});

0 commit comments

Comments
 (0)