diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 10d17c6270..3f8a3a2c52 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -39,10 +39,15 @@ pub async fn ws_handler( async fn handle_socket(mut socket: WebSocket, events: EventsSender) { let mut rx = events.subscribe(); while let Ok(msg) = rx.recv().await { - if let Ok(json) = serde_json::to_string(&msg) { - if socket.send(Message::Text(json)).await.is_err() { - tracing::info!("ws: client disconnected"); - return; + match serde_json::to_string(&msg) { + Ok(json) => { + if let Err(e) = socket.send(Message::Text(json)).await { + tracing::info!("ws: client disconnected: {e}"); + return; + } + } + Err(e) => { + tracing::error!("ws: error serializing message: {e}") } } } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 98bc624d35..34119653f1 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Tue Jun 17 12:21:43 UTC 2025 - Imobach Gonzalez Sosa + +- Improve logging of WebSocket events (gh#agama-project/agama#2479). + ------------------------------------------------------------------- Mon Jun 16 14:28:22 UTC 2025 - Ancor Gonzalez Sosa diff --git a/web/jest.config.js b/web/jest.config.js index 728e681612..f6891c0a25 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -23,7 +23,7 @@ module.exports = { collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ["src/**/*.{js,jsx}", "!src/lib/*.js"], + collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}"], // The directory where Jest should output its coverage files coverageDirectory: "coverage", diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 08da606cac..e0e5d89044 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,12 @@ +------------------------------------------------------------------- +Tue Jun 17 12:22:46 UTC 2025 - Imobach Gonzalez Sosa + +- Make sure queries data is in sync with the WebSocket messages + (bsc#1243276, gh#agama-project/agama#2479). +- Properly jump to the progress page when the product is selected + by a third party (e.g., "agama config load"). +- Log WebSocket messages in the console with "debug" log level. + ------------------------------------------------------------------- Fri Jun 13 08:24:59 UTC 2025 - Josef Reidinger diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8eb0297466..d7d1f58ec9 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -30,15 +30,6 @@ import { Product } from "./types/software"; jest.mock("~/client"); -jest.mock("~/api/l10n", () => ({ - ...jest.requireActual("~/api/l10n"), - fetchConfig: jest.fn().mockResolvedValue({ - uiKeymap: "en", - uiLocale: "en_US", - }), - updateConfig: jest.fn(), -})); - const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed", registration: false }; const microos: Product = { id: "Leap Micro", name: "openSUSE Micro", registration: false }; @@ -99,7 +90,7 @@ describe("App", () => { // setting the language through a cookie document.cookie = "agamaLang=en-US; path=/;"; (createClient as jest.Mock).mockImplementation(() => { - return {}; + return { isConnected: () => true }; }); mockProducts = [tumbleweed, microos]; @@ -116,7 +107,7 @@ describe("App", () => { }); it("renders the Loading screen", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText("Loading Mock"); }); }); @@ -128,7 +119,7 @@ describe("App", () => { }); it("renders the Loading screen", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText("Loading Mock"); }); }); @@ -145,7 +136,7 @@ describe("App", () => { }); it("redirects to product selection progress", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText("Navigating to /products/progress"); }); }); @@ -156,7 +147,7 @@ describe("App", () => { }); it("renders the application content", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText(/Outlet Content/); }); }); @@ -169,7 +160,7 @@ describe("App", () => { }); it("navigates to installation progress", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText("Navigating to /installation/progress"); }); }); @@ -181,7 +172,7 @@ describe("App", () => { }); it("navigates to installation finished", async () => { - installerRender(, { withL10n: true }); + installerRender(); await screen.findByText("Navigating to /installation/finished"); }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 290a58c7ab..f223b5e60e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,12 +20,9 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { ServerError } from "~/components/core"; import { Loading } from "~/components/layout"; -import { useInstallerL10n } from "~/context/installerL10n"; -import { useInstallerClientStatus } from "~/context/installer"; import { useProduct, useProductChanges } from "~/queries/software"; import { useL10nConfigChanges } from "~/queries/l10n"; import { useIssuesChanges } from "~/queries/issues"; @@ -33,27 +30,41 @@ import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status" import { useDeprecatedChanges } from "~/queries/storage"; import { ROOT, PRODUCT } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; +import { useQueryClient } from "@tanstack/react-query"; /** * Main application component. */ function App() { - const location = useLocation(); - const { isBusy, phase } = useInstallerStatus({ suspense: true }); - const { connected, error } = useInstallerClientStatus(); - const { selectedProduct, products } = useProduct({ - suspense: phase !== InstallationPhase.Install, - }); - const { language } = useInstallerL10n(); useL10nConfigChanges(); useProductChanges(); useIssuesChanges(); useInstallerStatusChanges(); useDeprecatedChanges(); - const Content = () => { - if (error) return ; + const location = useLocation(); + const { isBusy, phase } = useInstallerStatus({ suspense: true }); + const { selectedProduct, products } = useProduct({ + suspense: phase !== InstallationPhase.Install, + }); + const queryClient = useQueryClient(); + + useEffect(() => { + // Invalidate the queries when unmounting this component. + return () => { + queryClient.invalidateQueries(); + }; + }, [queryClient]); + console.log("App component", { + phase, + isBusy, + products, + selectedProduct, + location: location.pathname, + }); + + const Content = () => { if (phase === InstallationPhase.Install) { console.log("Navigating to the installation progress page"); return ; @@ -64,13 +75,7 @@ function App() { return ; } - if (!products || !connected || (selectedProduct === undefined && isBusy)) { - console.log("Loading screen: Initialization", { - products, - connected, - selectedProduct, - isBusy, - }); + if (!products) { return ; } @@ -79,7 +84,7 @@ function App() { return ; } - if (selectedProduct === undefined && location.pathname !== PRODUCT.root) { + if (selectedProduct === undefined && !isBusy && location.pathname !== PRODUCT.root) { console.log("Navigating to the product selection page"); return ; } @@ -92,8 +97,6 @@ function App() { return ; }; - if (!language) return null; - return ; } diff --git a/web/src/client/index.ts b/web/src/client/index.ts index 41f60c26b5..81c33e12db 100644 --- a/web/src/client/index.ts +++ b/web/src/client/index.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { WSClient, EventHandlerFn, ErrorHandlerFn } from "./ws"; +import { WSClient, EventHandlerFn, ErrorHandlerFn, WSClientIface } from "./ws"; type VoidFn = () => void; type BooleanFn = () => boolean; @@ -57,11 +57,11 @@ export type InstallerClient = { * * @param url - URL of the HTTP API. */ -const createClient = (url: URL): InstallerClient => { +const createClient = (url: URL, wsClient?: WSClientIface): InstallerClient => { url.hash = ""; url.pathname = url.pathname.concat("api/ws"); url.protocol = url.protocol === "http:" ? "ws" : "wss"; - const ws = new WSClient(url); + const ws = wsClient || new WSClient(url); const isConnected = () => ws.isConnected() || false; const isRecoverable = () => !!ws.isRecoverable(); diff --git a/web/src/client/ws.ts b/web/src/client/ws.ts index 529c1e3c43..8b00d9c9e8 100644 --- a/web/src/client/ws.ts +++ b/web/src/client/ws.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +import { noop } from "radashi"; + type RemoveFn = () => void; type BaseHandlerFn = () => void; export type EventHandlerFn = (event) => void; @@ -39,6 +41,20 @@ const SocketStates = Object.freeze({ const MAX_ATTEMPTS = 15; const ATTEMPT_INTERVAL = 1000; +// WebSocket client interface +// +// It defines the interface a WebSocket client should adhere to. +// The main point is to make it possible to replace the native +// WebSocket implementation with something else in the tests. +interface WSClientIface { + isConnected: () => boolean; + isRecoverable: () => boolean; + onOpen: (func: BaseHandlerFn) => RemoveFn; + onError: (func: ErrorHandlerFn) => RemoveFn; + onClose: (func: BaseHandlerFn) => RemoveFn; + onEvent: (func: EventHandlerFn) => RemoveFn; +} + /** * Agama WebSocket client. * @@ -46,7 +62,7 @@ const ATTEMPT_INTERVAL = 1000; * This class is not expected to be used directly, but through the * HTTPClient API. */ -class WSClient { +class WSClient implements WSClientIface { url: string; client: WebSocket; @@ -106,6 +122,7 @@ class WSClient { }; client.onmessage = (event) => { + console.debug("Event received", event); this.dispatchEvent(event); }; @@ -229,4 +246,32 @@ class WSClient { } } -export { WSClient }; +// WebSocket client to be used in the tests. +class DummyWSClient implements WSClientIface { + isConnected() { + return true; + } + + isRecoverable() { + return true; + } + + onOpen(): RemoveFn { + return noop; + } + + onError(): RemoveFn { + return noop; + } + + onClose(): RemoveFn { + return noop; + } + + onEvent(): RemoveFn { + return noop; + } +} + +export { WSClient, DummyWSClient }; +export type { WSClientIface }; diff --git a/web/src/components/core/ServerError.test.tsx b/web/src/components/core/ServerError.test.tsx index 3a486c6e02..2b33b7faac 100644 --- a/web/src/components/core/ServerError.test.tsx +++ b/web/src/components/core/ServerError.test.tsx @@ -23,9 +23,9 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { ServerError } from "~/components/core"; import { noop } from "radashi"; import * as utils from "~/utils"; +import ServerError from "./ServerError"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index 279c9a403d..c5d81865f3 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -38,7 +38,6 @@ export { default as Popup } from "./Popup"; export { default as ProgressReport } from "./ProgressReport"; export { default as ProgressText } from "./ProgressText"; export { default as PasswordInput } from "./PasswordInput"; -export { default as ServerError } from "./ServerError"; export { default as TreeTable } from "./TreeTable"; export { default as Link } from "./Link"; export { default as EmptyState } from "./EmptyState"; diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 322e3ab8d6..5d7a89d522 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -168,7 +168,9 @@ const Layout = ({ > }>{children || } - {location.pathname !== ROOT.login && } + {location.pathname !== ROOT.login && location.pathname !== ROOT.installationExit && ( + + )} ); }; diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index e54bc5d0ee..03ba2bf902 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -23,26 +23,79 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { mockApi, addMockApi, ApiData } from "~/mocks/api"; -import * as deviceFactory from "~/factories/storage/device"; import { StorageSection } from "~/components/overview"; -const defaultApiData: ApiData = { - "/api/storage/devices/available_drives": [], - "/api/storage/devices/available_md_raids": [], - "/api/storage/devices/system": [ - deviceFactory.generate({ sid: 59, name: "/dev/sda", size: 536870912000 }), - deviceFactory.generate({ sid: 60, name: "/dev/sdb", size: 697932185600 }), - ], +let mockModel = { + drives: [], }; +const sda = { + sid: 59, + name: "/dev/sda", + description: "", + isDrive: false, + type: "drive", + active: true, + encrypted: false, + shrinking: { unsuppored: [] }, + size: 536870912000, + start: 0, + systems: [], + udevIds: [], + udevPaths: [], +}; + +const sdb = { + sid: 60, + name: "/dev/sdb", + description: "", + isDrive: false, + type: "drive", + active: true, + encrypted: false, + shrinking: { unsuppored: [] }, + size: 697932185600, + start: 0, + systems: [], + udevIds: [], + udevPaths: [], +}; + +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockModel, +})); + +const mockDevices = [sda, sdb]; + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useDevices: () => mockDevices, +})); + +let mockAvailableDevices = [sda, sdb]; + +jest.mock("~/hooks/storage/system", () => ({ + ...jest.requireActual("~/hooks/storage/system"), + useAvailableDevices: () => mockAvailableDevices, +})); + +let mockSystemErrors = []; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useSystemErrors: () => mockSystemErrors, +})); + beforeEach(() => { - mockApi(defaultApiData); + mockSystemErrors = []; }); describe("when the configuration does not include any device", () => { beforeEach(() => { - addMockApi({ "/api/storage/config_model": { drives: [] } }); + mockModel = { + drives: [], + }; }); it("indicates that a device is not selected", async () => { @@ -54,11 +107,9 @@ describe("when the configuration does not include any device", () => { describe("when the configuration contains one drive", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [{ name: "/dev/sda", spacePolicy: "delete" }], - }, - }); + mockModel = { + drives: [{ name: "/dev/sda", spacePolicy: "delete" }], + }; }); it("renders the proposal summary", async () => { @@ -71,11 +122,9 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'resize'", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [{ name: "/dev/sda", spacePolicy: "resize" }], - }, - }); + mockModel = { + drives: [{ name: "/dev/sda", spacePolicy: "resize" }], + }; }); it("indicates that partitions may be shrunk", async () => { @@ -87,11 +136,9 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'keep'", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [{ name: "/dev/sda", spacePolicy: "keep" }], - }, - }); + mockModel = { + drives: [{ name: "/dev/sda", spacePolicy: "keep" }], + }; }); it("indicates that partitions will be kept", async () => { @@ -103,11 +150,9 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'custom'", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [{ name: "/dev/sda", spacePolicy: "custom" }], - }, - }); + mockModel = { + drives: [{ name: "/dev/sda", spacePolicy: "custom" }], + }; }); it("indicates that custom strategy for allocating space is set", async () => { @@ -119,11 +164,9 @@ describe("when the configuration contains one drive", () => { describe("and the drive matches no disk", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [{ name: undefined, spacePolicy: "delete" }], - }, - }); + mockModel = { + drives: [{ name: undefined, spacePolicy: "delete" }], + }; }); it("indicates that a device is not selected", async () => { @@ -136,14 +179,12 @@ describe("when the configuration contains one drive", () => { describe("when the configuration contains several drives", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "delete" }, - ], - }, - }); + mockModel = { + drives: [ + { name: "/dev/sda", spacePolicy: "delete" }, + { name: "/dev/sdb", spacePolicy: "delete" }, + ], + }; }); it("renders the proposal summary", async () => { @@ -155,14 +196,12 @@ describe("when the configuration contains several drives", () => { describe("but one of them has a different space policy", () => { beforeEach(() => { - addMockApi({ - "/api/storage/config_model": { - drives: [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "resize" }, - ], - }, - }); + mockModel = { + drives: [ + { name: "/dev/sda", spacePolicy: "delete" }, + { name: "/dev/sdb", spacePolicy: "resize" }, + ], + }; }); it("indicates that custom strategy for allocating space is set", async () => { @@ -175,15 +214,15 @@ describe("when the configuration contains several drives", () => { describe("when there is no configuration model (unsupported features)", () => { beforeEach(() => { - addMockApi({ "/api/storage/config_model": null }); + mockModel = null; }); describe("if the storage proposal succeeded", () => { - beforeEach(() => { - addMockApi({ "/api/storage/issues": [] }); - }); - describe("and there are no available devices", () => { + beforeEach(() => { + mockAvailableDevices = []; + }); + it("indicates that an unhandled configuration was used", async () => { plainRender(); await screen.findByText(/advanced configuration/); @@ -192,9 +231,7 @@ describe("when there is no configuration model (unsupported features)", () => { describe("and there are available disks", () => { beforeEach(() => { - addMockApi({ - "/api/storage/devices/available_drives": [59], - }); + mockAvailableDevices = [sda]; }); it("indicates that an unhandled configuration was used", async () => { @@ -206,20 +243,22 @@ describe("when there is no configuration model (unsupported features)", () => { describe("if the storage proposal was not possible", () => { beforeEach(() => { - addMockApi({ - "/api/storage/issues": [ - { - description: "System error", - kind: "storage", - details: "", - source: 1, - severity: 1, - }, - ], - }); + mockSystemErrors = [ + { + description: "System error", + kind: "storage", + details: "", + source: 1, + severity: 1, + }, + ]; }); describe("and there are no available devices", () => { + beforeEach(() => { + mockAvailableDevices = []; + }); + it("indicates that there are no available disks", async () => { plainRender(); await screen.findByText(/no disks available/); @@ -228,7 +267,7 @@ describe("when there is no configuration model (unsupported features)", () => { describe("and there are available devices", () => { beforeEach(() => { - addMockApi({ "/api/storage/devices/available_drives": [59] }); + mockAvailableDevices = [sda]; }); it("indicates that an unhandled configuration was used", async () => { diff --git a/web/src/components/product/ProductSelectionProgress.tsx b/web/src/components/product/ProductSelectionProgress.tsx index 09df5286d2..19525cfd8c 100644 --- a/web/src/components/product/ProductSelectionProgress.tsx +++ b/web/src/components/product/ProductSelectionProgress.tsx @@ -41,7 +41,7 @@ function ProductSelectionProgress() { ); diff --git a/web/src/context/installer.test.tsx b/web/src/context/installer.test.tsx index 7811ffec3d..5c4f500890 100644 --- a/web/src/context/installer.test.tsx +++ b/web/src/context/installer.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -22,40 +22,44 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { createDefaultClient } from "~/client"; +import { createClient } from "~/client"; import { plainRender } from "~/test-utils"; -import { InstallerClientProvider, useInstallerClientStatus } from "./installer"; +import { InstallerClientProvider } from "./installer"; +import { DummyWSClient } from "~/client/ws"; -jest.mock("~/client"); +jest.mock("~/components/layout/Loading", () => () =>
Loading Mock
); // Helper component to check the client status. -const ClientStatus = () => { - const { connected } = useInstallerClientStatus(); - - return ( -
    -
  • {`connected: ${connected}`}
  • -
- ); +const Content = () => { + return <>Content; }; describe("installer context", () => { - beforeEach(() => { - (createDefaultClient as jest.Mock).mockImplementation(() => { - return { - onConnect: jest.fn(), - onClose: jest.fn(), - onError: jest.fn(), - }; + describe("when the WebSocket is connected", () => { + it("renders the children", async () => { + const ws = new DummyWSClient(); + const client = createClient(new URL("https://localhost"), ws); + + plainRender( + + + , + ); + + await screen.findByText("Content"); }); }); - it("reports the status through the useInstallerClientStatus hook", async () => { - plainRender( - - - , - ); - await screen.findByText("connected: false"); + describe("when the WebSocket is not connected", () => { + it("renders the a loading indicator", async () => { + const client = createClient(new URL("https://localhost")); + + plainRender( + + + , + ); + await screen.findByText("Loading Mock"); + }); }); }); diff --git a/web/src/context/installer.tsx b/web/src/context/installer.tsx index 96fe40e0de..4a2f8c650f 100644 --- a/web/src/context/installer.tsx +++ b/web/src/context/installer.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2021-2024] SUSE LLC + * Copyright (c) [2021-2025] SUSE LLC * * All Rights Reserved. * @@ -22,13 +22,8 @@ import React, { useState, useEffect } from "react"; import { createDefaultClient, InstallerClient } from "~/client"; - -type ClientStatus = { - /** Whether the client is connected or not. */ - connected: boolean; - /** Whether the client present an error and cannot reconnect. */ - error: boolean; -}; +import Loading from "~/components/layout/Loading"; +import ServerError from "~/components/core/ServerError"; type InstallerClientProviderProps = React.PropsWithChildren<{ /** Client to connect to Agama service; if it is undefined, it instantiates a @@ -37,15 +32,9 @@ type InstallerClientProviderProps = React.PropsWithChildren<{ }>; const InstallerClientContext = React.createContext(null); -// TODO: we use a separate context to avoid changing all the codes to -// `useInstallerClient`. We should merge them in the future. -const InstallerClientStatusContext = React.createContext({ - connected: false, - error: false, -}); /** - * Returns the D-Bus installer client + * Returns the installer client */ function useInstallerClient(): InstallerClient { const context = React.useContext(InstallerClientContext); @@ -56,21 +45,9 @@ function useInstallerClient(): InstallerClient { return context; } -/** - * Returns the client status. - */ -function useInstallerClientStatus(): ClientStatus { - const context = React.useContext(InstallerClientStatusContext); - if (!context) { - throw new Error("useInstallerClientStatus must be used within a InstallerClientProvider"); - } - - return context; -} - function InstallerClientProvider({ children, client = null }: InstallerClientProviderProps) { const [value, setValue] = useState(client); - const [connected, setConnected] = useState(false); + const [connected, setConnected] = useState(!!client?.isConnected()); const [error, setError] = useState(false); useEffect(() => { @@ -108,13 +85,18 @@ function InstallerClientProvider({ children, client = null }: InstallerClientPro }); }, [value]); + const Content = () => { + if (error) return ; + if (!connected) return ; + + return children; + }; + return ( - - {children} - + ); } -export { InstallerClientProvider, useInstallerClient, useInstallerClientStatus }; +export { InstallerClientProvider, useInstallerClient }; diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index bc45339577..68f0f289ae 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -22,10 +22,10 @@ import React, { useCallback, useEffect, useState } from "react"; import { locationReload, setLocationSearch } from "~/utils"; -import { useInstallerClientStatus } from "./installer"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; -import { fetchConfig, updateConfig } from "~/api/l10n"; +import { fetchConfig as defaultFetchConfig, updateConfig } from "~/api/l10n"; +import { LocaleConfig } from "~/types/l10n"; const L10nContext = React.createContext(null); @@ -137,7 +137,7 @@ function languageToLocale(language: string): string { * * @return Language tag from the backend locale. */ -async function languageFromBackend(): Promise { +async function languageFromBackend(fetchConfig: () => Promise): Promise { const config = await fetchConfig(); return languageFromLocale(config.uiLocale); } @@ -228,27 +228,30 @@ async function loadTranslations(locale: string) { * * @param props * @param [props.children] - Content to display within the wrapper. + * @param [props.fetchConfigFn] - Function to retrieve l10n settings. * * @see useInstallerL10n */ function InstallerL10nProvider({ initialLanguage, + fetchConfigFn, children, }: { initialLanguage?: string; + fetchConfigFn?: () => Promise; children?: React.ReactNode; }) { - const { connected } = useInstallerClientStatus(); + const fetchConfig = fetchConfigFn || defaultFetchConfig; const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(undefined); const syncBackendLanguage = useCallback(async () => { - const backendLanguage = await languageFromBackend(); + const backendLanguage = await languageFromBackend(fetchConfig); if (backendLanguage === language) return; // FIXME: fallback to en-US if the language is not supported. await updateConfig({ uiLocale: languageToLocale(language) }); - }, [language]); + }, [fetchConfig, language]); const changeLanguage = useCallback( async (lang?: string) => { @@ -265,7 +268,7 @@ function InstallerL10nProvider({ wanted, wanted?.split("-")[0], // fallback to the language (e.g., "es" for "es-AR") agamaLanguage(), - await languageFromBackend(), + await languageFromBackend(fetchConfig), ].filter((l) => l); const newLanguage = findSupportedLanguage(candidateLanguages) || "en-US"; const mustReload = storeAgamaLanguage(newLanguage); @@ -280,17 +283,15 @@ function InstallerL10nProvider({ await loadTranslations(newLanguage); } }, - [setLanguage], + [fetchConfig, setLanguage], ); const changeKeymap = useCallback( async (id: string) => { - if (!connected) return; - setKeymap(id); await updateConfig({ uiKeymap: id }); }, - [setKeymap, connected], + [setKeymap], ); useEffect(() => { @@ -298,19 +299,19 @@ function InstallerL10nProvider({ }, [changeLanguage, language]); useEffect(() => { - if (!connected || !language) return; + if (!language) return; syncBackendLanguage(); - }, [connected, language, syncBackendLanguage]); + }, [language, syncBackendLanguage]); useEffect(() => { - if (!connected) return; - fetchConfig().then((c) => setKeymap(c.uiKeymap)); - }, [setKeymap, connected]); + }, [setKeymap, fetchConfig]); const value = { language, changeLanguage, keymap, changeKeymap }; + if (!language) return null; + return {children}; } diff --git a/web/src/router.tsx b/web/src/router.tsx index 177ee8e045..8abf4d4919 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -100,19 +100,6 @@ const protectedRoutes = () => [ }, ], }, - { - element: ( - - - - ), - children: [ - { - path: PATHS.installationExit, - element: , - }, - ], - }, ]; const router = () => @@ -125,6 +112,19 @@ const router = () => ), }, + { + element: ( + + + + ), + children: [ + { + path: PATHS.installationExit, + element: , + }, + ], + }, { path: PATHS.root, element: , diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 29704d201e..c74603f33f 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -37,6 +37,7 @@ import { createClient } from "~/client/index"; import { InstallerClientProvider } from "~/context/installer"; import { InstallerL10nProvider } from "~/context/installerL10n"; import { isObject, noop } from "radashi"; +import { DummyWSClient } from "./client/ws"; /** * Internal mock for manipulating routes, using ["/"] by default @@ -98,7 +99,8 @@ jest.mock("react-router-dom", () => ({ })); const Providers = ({ children, withL10n }) => { - const client = createClient(new URL("https://localhost")); + const ws = new DummyWSClient(); + const client = createClient(new URL("https://localhost"), ws); if (!client.onConnect) { client.onConnect = noop; @@ -111,9 +113,17 @@ const Providers = ({ children, withL10n }) => { } if (withL10n) { + const fetchConfig = async () => ({ + keymap: "us", + timezone: "Europe/Berlin", + uiLocale: "en_US", + uiKeymap: "us", + }); return ( - {children} + + {children} + ); }