Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7b59a07
fix(web): do not cache queries until the WS is connected
imobachgs Jun 13, 2025
0ad1f49
fix(web): invalidate queries when the WS is disconnected
imobachgs Jun 13, 2025
3fb2f09
fix(web): set InstallerClientProvider initial state
imobachgs Jun 16, 2025
a0d2130
refactor(web): use error and language handling out of App
imobachgs Jun 16, 2025
2f7409f
refactor(web): add a DummyWSClient for tests
imobachgs Jun 16, 2025
427268a
refactor(web): allow injecting a fetchConfig function for l10n
imobachgs Jun 16, 2025
0e37f92
refactor(web): mock hooks in StorageSection test
imobachgs Jun 16, 2025
9e87b48
refactor(web): drop the useInstallerStatus hook
imobachgs Jun 16, 2025
4e24b2b
fix(web): update installer context tests
imobachgs Jun 16, 2025
cfb8afa
fix(web): log events
imobachgs Jun 16, 2025
64813ba
fix(web): start listening for changes earlier
imobachgs Jun 17, 2025
659682a
fix(web): improve App logging
imobachgs Jun 17, 2025
9875289
fix(web): remove ServerError from core barrel file
dgdavid Jun 17, 2025
9257c59
fix(web): fix coverage reporting
imobachgs Jun 17, 2025
eb11680
fix(web): display the progress on "agama config load"
imobachgs Jun 17, 2025
e3c2dec
fix(web): make the "exit page" unprotected
imobachgs Jun 17, 2025
b99adee
chore(web): improve handling of WebSocket messages
imobachgs Jun 17, 2025
18bcaca
docs(rust): update changes file
imobachgs Jun 17, 2025
54244a1
docs(web): update changes file
imobachgs Jun 17, 2025
ba5672f
Merge branch 'master' into sync-queries
imobachgs Jun 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions rust/agama-server/src/web/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions rust/package/agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
-------------------------------------------------------------------
Tue Jun 17 12:21:43 UTC 2025 - Imobach Gonzalez Sosa <[email protected]>

- Improve logging of WebSocket events (gh#agama-project/agama#2479).

-------------------------------------------------------------------
Mon Jun 16 14:28:22 UTC 2025 - Ancor Gonzalez Sosa <[email protected]>

Expand Down
2 changes: 1 addition & 1 deletion web/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
Expand Down
9 changes: 9 additions & 0 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
-------------------------------------------------------------------
Tue Jun 17 12:22:46 UTC 2025 - Imobach Gonzalez Sosa <[email protected]>

- 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌


-------------------------------------------------------------------

Fri Jun 13 08:24:59 UTC 2025 - Josef Reidinger <[email protected]>
Expand Down
23 changes: 7 additions & 16 deletions web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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];
Expand All @@ -116,7 +107,7 @@ describe("App", () => {
});

it("renders the Loading screen", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText("Loading Mock");
});
});
Expand All @@ -128,7 +119,7 @@ describe("App", () => {
});

it("renders the Loading screen", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText("Loading Mock");
});
});
Expand All @@ -145,7 +136,7 @@ describe("App", () => {
});

it("redirects to product selection progress", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText("Navigating to /products/progress");
});
});
Expand All @@ -156,7 +147,7 @@ describe("App", () => {
});

it("renders the application content", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText(/Outlet Content/);
});
});
Expand All @@ -169,7 +160,7 @@ describe("App", () => {
});

it("navigates to installation progress", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText("Navigating to /installation/progress");
});
});
Expand All @@ -181,7 +172,7 @@ describe("App", () => {
});

it("navigates to installation finished", async () => {
installerRender(<App />, { withL10n: true });
installerRender(<App />);
await screen.findByText("Navigating to /installation/finished");
});
});
Expand Down
49 changes: 26 additions & 23 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,51 @@
* 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";
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 <ServerError />;
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 <Navigate to={ROOT.installationProgress} />;
Expand All @@ -64,13 +75,7 @@ function App() {
return <Navigate to={ROOT.installationFinished} />;
}

if (!products || !connected || (selectedProduct === undefined && isBusy)) {
console.log("Loading screen: Initialization", {
products,
connected,
selectedProduct,
isBusy,
});
if (!products) {
return <Loading listenQuestions />;
}

Expand All @@ -79,7 +84,7 @@ function App() {
return <Loading listenQuestions />;
}

if (selectedProduct === undefined && location.pathname !== PRODUCT.root) {
if (selectedProduct === undefined && !isBusy && location.pathname !== PRODUCT.root) {
console.log("Navigating to the product selection page");
return <Navigate to={PRODUCT.root} />;
}
Expand All @@ -92,8 +97,6 @@ function App() {
return <Outlet />;
};

if (!language) return null;

return <Content />;
}

Expand Down
6 changes: 3 additions & 3 deletions web/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 47 additions & 2 deletions web/src/client/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,14 +41,28 @@ 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.
*
* Connects to the Agama WebSocket server and reacts on the events.
* This class is not expected to be used directly, but through the
* HTTPClient API.
*/
class WSClient {
class WSClient implements WSClientIface {
url: string;

client: WebSocket;
Expand Down Expand Up @@ -106,6 +122,7 @@ class WSClient {
};

client.onmessage = (event) => {
console.debug("Event received", event);
this.dispatchEvent(event);
};

Expand Down Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion web/src/components/core/ServerError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => () => (
<div>ProductRegistrationAlert Mock</div>
Expand Down
1 change: 0 additions & 1 deletion web/src/components/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ const Layout = ({
>
<Suspense fallback={<Loading />}>{children || <Outlet />}</Suspense>
</Page>
{location.pathname !== ROOT.login && <Questions />}
{location.pathname !== ROOT.login && location.pathname !== ROOT.installationExit && (
<Questions />
)}
</>
);
};
Expand Down
Loading