feat: implement backend error handling and improve error messaging in Server component

This commit is contained in:
hamster1963
2026-07-05 13:17:36 +08:00
parent d295d549da
commit 056c357b6e
12 changed files with 187 additions and 27 deletions
+56 -7
View File
@@ -25,7 +25,12 @@ vi.mock("../components/Header", () => ({
}));
vi.mock("../pages/Server", () => ({
default: () => <div>server-page</div>,
default: ({ backendError }: { backendError?: Error | null }) => (
<div>
<div>server-page</div>
{backendError && <p>{backendError.message}</p>}
</div>
),
}));
vi.mock("../pages/ServerDetail", () => ({
@@ -69,11 +74,14 @@ function renderApp(route = "/") {
window.history.pushState({}, "", route);
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
return {
queryClient,
...render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
),
};
}
describe("App", () => {
@@ -110,6 +118,20 @@ describe("App", () => {
).toBe(true);
});
it("renders the app shell while initial settings are still pending", async () => {
appMocks.fetchSetting.mockImplementation(
() => new Promise(() => undefined),
);
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
});
it("injects custom code before showing the app shell", async () => {
appMocks.fetchSetting.mockResolvedValue(
settingResponse("<script>custom</script>"),
@@ -125,14 +147,41 @@ describe("App", () => {
expect(await screen.findByText("server-page")).toBeInTheDocument();
});
it("renders fetch errors through the error page", async () => {
it("renders the app shell when initial settings fetch fails", async () => {
appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
expect(await screen.findByText("settings failed")).toBeInTheDocument();
});
it("keeps rendering with stale settings when a later settings refetch fails", async () => {
let requestCount = 0;
appMocks.fetchSetting.mockImplementation(() => {
requestCount += 1;
return requestCount === 1
? Promise.resolve(settingResponse())
: Promise.reject(new Error("settings failed"));
});
const { queryClient } = renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
await queryClient.refetchQueries({ queryKey: ["setting"] });
await waitFor(() => {
expect(appMocks.fetchSetting.mock.calls.length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByText("server-page")).toBeInTheDocument();
expect(screen.queryByText("settings failed")).not.toBeInTheDocument();
});
it("routes server detail paths through the app router", async () => {
renderApp("/server/42");
@@ -180,6 +180,17 @@ describe("ServerFlag", () => {
expect(container.querySelector(".fi-us")).toBeInTheDocument();
});
it("normalizes SVG flag classes and ignores invalid country codes", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container, rerender } = render(<ServerFlag country_code="US" />);
expect(container.querySelector(".fi-us")).toBeInTheDocument();
rerender(<ServerFlag country_code="u" />);
expect(container).toBeEmptyDOMElement();
});
it("uses emoji flags when the canvas probe detects support", async () => {
Object.assign(window, { ForceUseSvgFlag: false });
const originalCreateElement = document.createElement.bind(document);
+41 -2
View File
@@ -91,7 +91,10 @@ function StatusControl() {
function renderServerPage(
websocketValue: Partial<WebSocketContextType>,
{ withStatusControl = false } = {},
{
backendError = null,
withStatusControl = false,
}: { backendError?: Error | null; withStatusControl?: boolean } = {},
) {
const defaultWebsocketValue: WebSocketContextType = {
lastData: null,
@@ -109,7 +112,7 @@ function renderServerPage(
value={{ ...defaultWebsocketValue, ...websocketValue }}
>
{withStatusControl && <StatusControl />}
<Servers />
<Servers backendError={backendError} />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
@@ -178,6 +181,42 @@ describe("Servers page", () => {
expect(screen.getByText("info.processing")).toBeInTheDocument();
});
it("renders a centered backend error instead of a 500 page", async () => {
renderServerPage(
{
connected: false,
lastData: null,
},
{ backendError: new Error("settings failed") },
);
expect(
screen.getByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(
screen.getByText("error.backendUnavailableDescription"),
).toBeInTheDocument();
expect(screen.getByText("settings failed")).toBeInTheDocument();
expect(
screen.queryByText("info.websocketConnecting"),
).not.toBeInTheDocument();
});
it("shows backend query errors while waiting for websocket data", async () => {
apiMocks.fetchServerGroup.mockRejectedValue(new Error("group failed"));
apiMocks.fetchService.mockRejectedValue(new Error("service failed"));
renderServerPage({
connected: false,
lastData: null,
});
expect(
await screen.findByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(screen.getByText("group failed")).toBeInTheDocument();
});
it("summarizes online and offline servers from websocket data", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({