Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package agama-web-ui for openSUSE:Factory 
checked in at 2026-09-22 15:48:40
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/agama-web-ui (Old)
 and      /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "agama-web-ui"

Tue Sep 22 15:48:40 2026 rev:53 rq:1379493 version:0

Changes:
--------
--- /work/SRC/openSUSE:Factory/agama-web-ui/agama-web-ui.changes        
2026-09-04 12:36:18.738949599 +0200
+++ /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539/agama-web-ui.changes    
2026-09-22 15:48:50.255291327 +0200
@@ -1,0 +2,29 @@
+Fri Sep 18 14:30:33 UTC 2026 - David Diaz <[email protected]>
+
+- Ask to accept each license of a product requiring several
+  (jsc#PED-16988, gh#agama-project/agama#3960).
+
+-------------------------------------------------------------------
+Fri Sep 18 14:14:23 UTC 2026 - José Iván López González <[email protected]>
+
+- Fix progress tracking (related to bsc#1280751).
+
+-------------------------------------------------------------------
+Thu Sep 17 16:00:00 UTC 2026 - David Diaz <[email protected]>
+
+- Show the name of the package being installed in one piece and stop
+  the progress text from moving as packages go by (bsc#1279489).
+
+-------------------------------------------------------------------
+Tue Sep 15 16:11:54 UTC 2026 - José Iván López González <[email protected]>
+
+- Fix formatting of the storage commit errors
+  (gh#agama-project/agama#3912).
+
+-------------------------------------------------------------------
+Mon Sep 14 12:51:32 UTC 2026 - David Diaz <[email protected]>
+
+- Remove empty space due to a byte order mark which kills
+  PatternFly's base reset (bsc#1280220).
+
+-------------------------------------------------------------------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ _service ++++++
--- /var/tmp/diff_new_pack.okt1Fk/_old  2026-09-22 15:49:04.326875183 +0200
+++ /var/tmp/diff_new_pack.okt1Fk/_new  2026-09-22 15:49:04.332875432 +0200
@@ -8,7 +8,7 @@
     <param name="scm">git</param>
     <!-- the revision might be changed to "release" branch or a git tag by the
     .github/workflows/obs-staging-shared.yml action when submitting to OBS -->
-    <param name="revision">master</param>
+    <param name="revision">SLE-16.1</param>
     <param name="subdir">web</param>
     <param name="without-version">enable</param>
     <param name="extract">package-lock.json</param>

++++++ agama.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/package/agama-web-ui.changes 
new/agama/package/agama-web-ui.changes
--- old/agama/package/agama-web-ui.changes      2026-09-02 11:36:48.000000000 
+0200
+++ new/agama/package/agama-web-ui.changes      2026-09-21 16:43:04.000000000 
+0200
@@ -1,4 +1,33 @@
 -------------------------------------------------------------------
+Fri Sep 18 14:30:33 UTC 2026 - David Diaz <[email protected]>
+
+- Ask to accept each license of a product requiring several
+  (jsc#PED-16988, gh#agama-project/agama#3960).
+
+-------------------------------------------------------------------
+Fri Sep 18 14:14:23 UTC 2026 - José Iván López González <[email protected]>
+
+- Fix progress tracking (related to bsc#1280751).
+
+-------------------------------------------------------------------
+Thu Sep 17 16:00:00 UTC 2026 - David Diaz <[email protected]>
+
+- Show the name of the package being installed in one piece and stop
+  the progress text from moving as packages go by (bsc#1279489).
+
+-------------------------------------------------------------------
+Tue Sep 15 16:11:54 UTC 2026 - José Iván López González <[email protected]>
+
+- Fix formatting of the storage commit errors
+  (gh#agama-project/agama#3912).
+
+-------------------------------------------------------------------
+Mon Sep 14 12:51:32 UTC 2026 - David Diaz <[email protected]>
+
+- Remove empty space due to a byte order mark which kills
+  PatternFly's base reset (bsc#1280220).
+
+-------------------------------------------------------------------
 Tue Sep  1 15:51:08 UTC 2026 - Imobach Gonzalez Sosa <[email protected]>
 
 - Version 24
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/api.ts new/agama/src/api.ts
--- old/agama/src/api.ts        2026-09-02 11:36:48.000000000 +0200
+++ new/agama/src/api.ts        2026-09-21 16:43:04.000000000 +0200
@@ -43,8 +43,7 @@
 
 const getSystem = (): Promise<System | null> => get("/api/system");
 
-const getLicense = (id: string, lang: string = "en"): Promise<LicenseContent> 
=>
-  get(`/api/licenses/${id}?lang=${lang}`);
+const getLicense = (id: string): Promise<LicenseContent> => 
get(`/api/licenses/${id}`);
 
 const getProposal = (): Promise<Proposal | null> => get("/api/proposal");
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/assets/styles/components/_agama-custom.scss 
new/agama/src/assets/styles/components/_agama-custom.scss
--- old/agama/src/assets/styles/components/_agama-custom.scss   2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/assets/styles/components/_agama-custom.scss   2026-09-21 
16:43:04.000000000 +0200
@@ -219,6 +219,15 @@
   }
 }
 
+// Opt-in for content that must keep its size while it changes, so it does not
+// move the rest of the screen around (see the stretchFirstRowEnd prop). It is
+// aligned to the start, having no box of its own to be centered in. It comes
+// after the rules above on purpose, to win over the one it shares weight with.
+.agm-split-info-layout--stretch-first-row-end > 
.pf-v6-l-grid__item[style*="Order-on-md: 4"] {
+  justify-self: stretch;
+  text-align: start;
+}
+
 // Terminal panel docked below the application as a resizable split. The panel
 // lives above the page outlet, so its session survives navigation; here it 
only
 // shares the screen height with the application and provides the resize
@@ -321,3 +330,20 @@
   white-space: pre;
   overflow: auto;
 }
+
+// PatternFly's Truncate puts the start of the text and its final characters
+// side by side. Given more room than the text needs, it pushes them to
+// opposite ends, so a shortened name reads as a broken one. Packed together,
+// it stays one name.
+.pf-v6-c-truncate {
+  justify-content: start;
+}
+
+// What is being installed right now, under the current step. The name changes
+// several times a second and the line it sits on takes the room the screen
+// gives it, so nothing moves as one package follows another and a name too
+// long for the line is shortened instead of spilling out. The maximum keeps
+// it short enough to stay comfortable to read on a wide screen.
+.agm-progress-detail {
+  max-inline-size: 80ch;
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/assets/styles/utilities/_index.scss 
new/agama/src/assets/styles/utilities/_index.scss
--- old/agama/src/assets/styles/utilities/_index.scss   2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/assets/styles/utilities/_index.scss   2026-09-21 
16:43:04.000000000 +0200
@@ -8,10 +8,16 @@
 }
 
 // Some utilities not found at PF
+
 .w-14ch {
   inline-size: 14ch;
 }
 
+.mh-60dvh {
+  max-height: 60dvh;
+  overflow: auto;
+}
+
 // Balance multi-line text so wrapped lines are more even. The base reset
 // already applies this to bare h1-h4; this utility covers other elements
 // (paragraphs, helper text, PF Title/Text/Content components).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/core/InstallationProgress.tsx 
new/agama/src/components/core/InstallationProgress.tsx
--- old/agama/src/components/core/InstallationProgress.tsx      2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/core/InstallationProgress.tsx      2026-09-21 
16:43:04.000000000 +0200
@@ -36,6 +36,7 @@
     <Page noDefaultProgressMonitor>
       <Page.Content>
         <SplitInfoLayout
+          stretchFirstRowEnd
           icon="deployed_code_update"
           firstRowStart={
             <>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/core/ProgressReport.tsx 
new/agama/src/components/core/ProgressReport.tsx
--- old/agama/src/components/core/ProgressReport.tsx    2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/core/ProgressReport.tsx    2026-09-21 
16:43:04.000000000 +0200
@@ -61,7 +61,11 @@
       if (detail && detail.step !== "") {
         const { step: message, index, size } = detail;
         properties.description = (
-          <Flex direction={{ default: "column" }} rowGap={{ default: 
"rowGapXs" }}>
+          <Flex
+            direction={{ default: "column" }}
+            rowGap={{ default: "rowGapXs" }}
+            className="agm-progress-detail"
+          >
             <Truncate content={message} trailingNumChars={12} 
position="middle" />
             <Text component="small">{sprintf(_("Step %1$d of %2$d"), index, 
size)}</Text>
           </Flex>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/layout/SplitInfoLayout.tsx 
new/agama/src/components/layout/SplitInfoLayout.tsx
--- old/agama/src/components/layout/SplitInfoLayout.tsx 2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/layout/SplitInfoLayout.tsx 2026-09-21 
16:43:04.000000000 +0200
@@ -37,6 +37,16 @@
    * Content for right side of second row (typically secondary action button)
    */
   secondRowEnd?: React.ReactNode;
+
+  /**
+   * Whether `firstRowEnd` takes all the room it is given instead of being
+   * sized around itself.
+   *
+   * Turn it on when that content changes while the screen is shown and its
+   * size must not follow, so nothing moves as it changes. The content is then
+   * aligned to the start, having no box of its own to be centered in.
+   */
+  stretchFirstRowEnd?: boolean;
 };
 
 /**
@@ -82,6 +92,9 @@
  * NOTE: The prop names "Row" and "Start/End" refer to the viewport over "md"
  * breakpoint layout, not the small viewport layout. On small viewports, all
  * content stacks vertically regardless of the "start/end" naming.
+ *
+ * Each piece of content is sized around itself, unless `stretchFirstRowEnd`
+ * is set.
  */
 export default function SplitInfoLayout({
   icon,
@@ -90,10 +103,18 @@
   firstRowEnd,
   secondRowStart,
   secondRowEnd,
+  stretchFirstRowEnd = false,
 }: SplitInfoLayoutProps) {
+  const className = [
+    "agm-split-info-layout",
+    stretchFirstRowEnd && "agm-split-info-layout--stretch-first-row-end",
+  ]
+    .filter(Boolean)
+    .join(" ");
+
   return (
     <Grid className="agm-split-info-layout-container">
-      <Grid hasGutter className="agm-split-info-layout">
+      <Grid hasGutter className={className}>
         {icon && (
           <>
             <GridItem span={12} md={6} order={{ md: "1" }}>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/product/LicenseDialog.test.tsx 
new/agama/src/components/product/LicenseDialog.test.tsx
--- old/agama/src/components/product/LicenseDialog.test.tsx     2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/product/LicenseDialog.test.tsx     2026-09-21 
16:43:04.000000000 +0200
@@ -24,24 +24,15 @@
 import { screen, waitFor } from "@testing-library/react";
 import { installerRender, mockL10n, loadTranslations } from "~/test-utils";
 import { useSystem } from "~/hooks/model/system";
-import { Product } from "~/model/system";
+import { License } from "~/model/system";
 import * as api from "~/api";
 import { Locale, Keymap } from "~/model/system/l10n";
 import LicenseDialog from "./LicenseDialog";
 
-const sle: Product = {
-  id: "SLE",
-  name: "SUSE Linux Enterprise",
-  modes: [],
-  icon: "sle.svg",
-  description: "SLE description",
-  registration: true,
-  license: "license.sle",
-};
+const license: License = { id: "license.sle", name: "SUSE Linux Enterprise 
License" };
 
 const mockUILanguage = "de-DE";
 let mockLicenseLanguage = "de-DE";
-const product: Product = sle;
 const onCloseFn = jest.fn();
 let mockGetLicense: jest.SpyInstance;
 
@@ -85,10 +76,20 @@
     );
   });
 
-  it("loads given product license in the interface language", async () => {
-    installerRender(<LicenseDialog product={product} onClose={onCloseFn} />);
+  it("uses the license name as title", async () => {
+    installerRender(<LicenseDialog license={license} onClose={onCloseFn} />);
+    await screen.findByRole("dialog", { name: license.name });
+  });
+
+  it("uses the given title instead of the license name", async () => {
+    installerRender(<LicenseDialog license={license} title="SLES" 
onClose={onCloseFn} />);
+    await screen.findByRole("dialog", { name: "SLES" });
+  });
+
+  it("loads the given license", async () => {
+    installerRender(<LicenseDialog license={license} onClose={onCloseFn} />);
     await waitFor(() => {
-      expect(mockGetLicense).toHaveBeenCalledWith(sle.license, mockUILanguage);
+      expect(mockGetLicense).toHaveBeenCalledWith(license.id);
       screen.getByText("El contenido de la licencia");
     });
   });
@@ -99,9 +100,9 @@
     });
 
     it("it warns the user that the license is not translated", async () => {
-      installerRender(<LicenseDialog product={product} onClose={onCloseFn} />);
+      installerRender(<LicenseDialog license={license} onClose={onCloseFn} />);
       await waitFor(() => {
-        expect(mockGetLicense).toHaveBeenCalledWith(sle.license, 
mockUILanguage);
+        expect(mockGetLicense).toHaveBeenCalledWith(license.id);
         screen.getByText("El contenido de la licencia");
         screen.getByText("Diese Lizenz ist in Deutsch nicht verfügbar.");
       });
@@ -109,7 +110,7 @@
   });
 
   it("triggers given callback on Close click", async () => {
-    const { user } = installerRender(<LicenseDialog product={product} 
onClose={onCloseFn} />);
+    const { user } = installerRender(<LicenseDialog license={license} 
onClose={onCloseFn} />);
     const closeButton = await screen.findByRole("button", { name: "Schließen" 
});
     await user.click(closeButton);
     expect(onCloseFn).toHaveBeenCalled();
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/product/LicenseDialog.tsx 
new/agama/src/components/product/LicenseDialog.tsx
--- old/agama/src/components/product/LicenseDialog.tsx  2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/product/LicenseDialog.tsx  2026-09-21 
16:43:04.000000000 +0200
@@ -23,7 +23,7 @@
 import React, { useEffect, useState } from "react";
 import { Alert, ModalProps, Stack } from "@patternfly/react-core";
 import { Popup } from "~/components/core";
-import { Product } from "~/model/system";
+import type { License } from "~/model/system";
 import { getLicense } from "~/api";
 import { useInstallerL10n } from "~/context/installerL10n";
 import { sprintf } from "sprintf-js";
@@ -48,27 +48,34 @@
   return lang1 === lang2;
 };
 
-function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; 
product: Product }) {
+type LicenseDialogProps = {
+  /** The license to display */
+  license: License;
+  /** The dialog title, the license name by default */
+  title?: string;
+  /** Callback fired when the dialog is closed */
+  onClose: ModalProps["onClose"];
+};
+
+function LicenseDialog({ license, title = license.name, onClose }: 
LicenseDialogProps) {
   const { language: uiLanguage } = useInstallerL10n();
-  const [language] = useState<string>(uiLanguage);
   const [licenseLanguage, setLicenseLanguage] = useState<string | 
null>(undefined);
-  const [license, setLicense] = useState<string>();
+  const [licenseContent, setLicenseContent] = useState<string>();
 
   useEffect(() => {
-    language &&
-      getLicense(product.license, language).then(({ body, language: 
foundLanguage }) => {
-        setLicense(body);
-        setLicenseLanguage(foundLanguage);
-      });
-  }, [language, product.license]);
+    getLicense(license.id).then(({ body, language }) => {
+      setLicenseContent(body);
+      setLicenseLanguage(language);
+    });
+  }, [license.id]);
 
   return (
-    <Popup isOpen title={product.name} width="auto">
+    <Popup isOpen title={title} width="auto">
       <Stack hasGutter>
         {licenseLanguage && !languagesMatches(uiLanguage, licenseLanguage) && (
           <MissingTranslation missing={uiLanguage} />
         )}
-        <pre>{license}</pre>
+        <pre>{licenseContent}</pre>
       </Stack>
       <Popup.Actions>
         <Popup.Confirm onClick={onClose}>{_("Close")}</Popup.Confirm>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/product/ProductSelectionPage.test.tsx 
new/agama/src/components/product/ProductSelectionPage.test.tsx
--- old/agama/src/components/product/ProductSelectionPage.test.tsx      
2026-09-02 11:36:48.000000000 +0200
+++ new/agama/src/components/product/ProductSelectionPage.test.tsx      
2026-09-21 16:43:04.000000000 +0200
@@ -34,7 +34,7 @@
 import { useSystem as useSystemSoftware } from "~/hooks/model/system/software";
 import { ROOT } from "~/routes/paths";
 import ProductSelectionPage from "./ProductSelectionPage";
-import { Product } from "~/model/system";
+import { License, Product } from "~/model/system";
 
 const tumbleweed: Product = {
   id: "Tumbleweed",
@@ -42,6 +42,7 @@
   icon: "tumbleweed.svg",
   description: "Tumbleweed description...",
   registration: false,
+  licenses: [],
   modes: [],
 };
 
@@ -51,10 +52,25 @@
   icon: "microos.svg",
   description: "MicroOS description",
   registration: false,
-  license: "fake.license",
+  licenses: ["fake.license"],
   modes: [],
 };
 
+const serverForApps: Product = {
+  id: "ServerForApps",
+  name: "Server for Apps",
+  icon: "server.svg",
+  description: "Server with application licenses",
+  registration: false,
+  licenses: ["license.server", "license.apps"],
+  modes: [],
+};
+
+const licenses: License[] = [
+  { id: "license.server", name: "Server License" },
+  { id: "license.apps", name: "Apps License" },
+];
+
 const productWithModes: Product = {
   id: "SLES",
   name: "SUSE Linux Enterprise Server",
@@ -77,7 +93,9 @@
   <div>InstallerL10nOptions Mock</div>
 ));
 
-jest.mock("~/components/product/LicenseDialog", () => () => <div>LicenseDialog 
Mock</div>);
+jest.mock("~/components/product/LicenseDialog", () => ({ title }) => (
+  <div>LicenseDialog Mock: {title}</div>
+));
 
 jest.mock("~/api", () => ({
   ...jest.requireActual("~/api"),
@@ -199,6 +217,78 @@
     expect(licenseCheckbox).not.toBeChecked();
   });
 
+  describe("when the selected product requires several licenses", () => {
+    beforeEach(() => {
+      mockProduct(undefined);
+      mockSystem({ products: [microOs, serverForApps], licenses });
+    });
+
+    it("renders one checkbox per license, named after it", async () => {
+      const { user } = installerRender(<ProductSelectionPage />);
+      await user.click(screen.getByRole("radio", { name: serverForApps.name 
}));
+      screen.getByRole("checkbox", { name: "I have read and accept the Server 
License" });
+      screen.getByRole("checkbox", { name: "I have read and accept the Apps 
License" });
+    });
+
+    it("uses the product text for a license the system does not know", async 
() => {
+      mockSystem({
+        products: [{ ...serverForApps, licenses: ["license.server", 
"license.unknown"] }],
+        licenses,
+      });
+      const { user } = installerRender(<ProductSelectionPage />);
+      await user.click(screen.getByRole("radio", { name: serverForApps.name 
}));
+      screen.getByRole("checkbox", { name: "I have read and accept the Server 
License" });
+      screen.getByRole("checkbox", {
+        name: `I have read and accept the license for ${serverForApps.name}`,
+      });
+    });
+
+    it("allows the selection only when all the licenses are accepted", async 
() => {
+      const { user } = installerRender(<ProductSelectionPage />);
+      const selectButton = screen.getByRole("button", { name: "Select" });
+      await user.click(screen.getByRole("radio", { name: serverForApps.name 
}));
+      const serverCheckbox = screen.getByRole("checkbox", {
+        name: "I have read and accept the Server License",
+      });
+      const appsCheckbox = screen.getByRole("checkbox", {
+        name: "I have read and accept the Apps License",
+      });
+
+      await user.click(serverCheckbox);
+      expect(serverCheckbox).toBeChecked();
+      expect(appsCheckbox).not.toBeChecked();
+      expect(selectButton).toBeDisabled();
+      screen.getByText("License acceptance is required to continue.");
+
+      await user.click(appsCheckbox);
+      expect(selectButton).not.toBeDisabled();
+
+      await user.click(serverCheckbox);
+      expect(selectButton).toBeDisabled();
+    });
+
+    it("resets the acceptance of all the licenses when switching products", 
async () => {
+      const { user } = installerRender(<ProductSelectionPage />);
+      await user.click(screen.getByRole("radio", { name: serverForApps.name 
}));
+      await user.click(
+        screen.getByRole("checkbox", { name: "I have read and accept the 
Server License" }),
+      );
+      await user.click(
+        screen.getByRole("checkbox", { name: "I have read and accept the Apps 
License" }),
+      );
+
+      await user.click(screen.getByRole("radio", { name: microOs.name }));
+      await user.click(screen.getByRole("radio", { name: serverForApps.name 
}));
+
+      expect(
+        screen.getByRole("checkbox", { name: "I have read and accept the 
Server License" }),
+      ).not.toBeChecked();
+      expect(
+        screen.getByRole("checkbox", { name: "I have read and accept the Apps 
License" }),
+      ).not.toBeChecked();
+    });
+  });
+
   it("navigates to root path when product is registered (registration 
exists)", async () => {
     mockUseSystemSoftwareFn.mockReturnValue({
       addons: [],
@@ -845,6 +935,20 @@
       within(section).getByRole("button", { name: "View license" });
     });
 
+    it("lists the accepted licenses, each named after it, for products with 
several licenses", () => {
+      mockProduct(serverForApps);
+      mockSystem({ products: [tumbleweed, serverForApps], licenses });
+      installerRender(<ProductSelectionPage />);
+
+      const sectionHeading = screen.getByRole("heading", { level: 2, name: 
"Current selection" });
+      const section = sectionHeading.closest("section");
+      within(section).getByRole("heading", { level: 3, name: "Accepted 
licenses" });
+      const list = within(section).getByRole("list");
+      within(list).getByRole("button", { name: "Server License" });
+      within(list).getByRole("button", { name: "Apps License" });
+      expect(within(section).queryByRole("button", { name: "View license" 
})).toBeNull();
+    });
+
     it("does not render view license button for products without license", () 
=> {
       mockProduct(tumbleweed);
       installerRender(<ProductSelectionPage />);
@@ -865,13 +969,13 @@
   });
 
   describe("LicenseButton", () => {
-    it("opens license dialog", async () => {
+    it("opens license dialog titled after the product", async () => {
       mockProduct(microOs);
       const { user } = installerRender(<ProductSelectionPage />);
 
       const viewLicenseButton = screen.getByRole("button", { name: "View 
license" });
       await user.click(viewLicenseButton);
-      screen.getByText("LicenseDialog Mock");
+      screen.getByText(`LicenseDialog Mock: ${microOs.name}`);
     });
   });
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/product/ProductSelectionPage.tsx 
new/agama/src/components/product/ProductSelectionPage.tsx
--- old/agama/src/components/product/ProductSelectionPage.tsx   2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/product/ProductSelectionPage.tsx   2026-09-21 
16:43:04.000000000 +0200
@@ -21,7 +21,7 @@
  */
 
 import React, { useDeferredValue, useEffect, useState } from "react";
-import { isEmpty } from "radashi";
+import { isEmpty, toggle } from "radashi";
 import { sprintf } from "sprintf-js";
 import {
   Button,
@@ -54,17 +54,37 @@
 import { Page, SubtleContent } from "~/components/core";
 import ProductLogo from "~/components/product/ProductLogo";
 import LicenseDialog from "~/components/product/LicenseDialog";
+import Interpolate from "~/components/core/Interpolate";
 import Text from "~/components/core/Text";
 import { patchConfig, putConfig } from "~/api";
 import { useProduct, useProductInfo } from "~/hooks/model/config/product";
 import { useSystem } from "~/hooks/model/system";
 import { useSystem as useSystemSoftware } from "~/hooks/model/system/software";
 import { ROOT } from "~/routes/paths";
-import { Mode, Product } from "~/model/system";
 import { n_, _ } from "~/i18n";
 
 import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text";
 
+import type { License, Mode, Product } from "~/model/system";
+
+/**
+ * Returns the licenses the user must accept to install the given product.
+ *
+ * Names come from the licenses known by the system. A license the system does
+ * not know, or reports without a name, is named after the product.
+ */
+const productLicenses = (product: Product | undefined, licenses: License[] = 
[]): License[] =>
+  (product?.licenses || []).map((id) => {
+    const name = licenses.find((l) => l.id === id)?.name;
+    return { id, name: name || product.name };
+  });
+
+/**
+ * Whether the system knows the given license by name.
+ */
+const hasKnownName = (license: License, licenses: License[] = []): boolean =>
+  licenses.some((l) => l.id === license.id && !isEmpty(l.name));
+
 /**
  * Props for ProductFormProductOption component
  */
@@ -130,9 +150,9 @@
                 }
                 body={
                   <Stack hasGutter id={detailsId}>
-                    {(product.license || product.modes) && (
+                    {(!isEmpty(product.licenses) || product.modes) && (
                       <Split hasGutter>
-                        {product.license && (
+                        {!isEmpty(product.licenses) && (
                           <Label variant="outline" isCompact>
                             <Text component="small">{_("License acceptance 
required")}</Text>
                           </Label>
@@ -187,14 +207,16 @@
  * Props for LicenseButton component
  */
 type LicenseButtonProps = Omit<ButtonProps, "onClick"> & {
-  /** The product whose license will be displayed */
-  product: Product;
+  /** The license to display */
+  license: License;
+  /** The dialog title, the license name by default */
+  dialogTitle?: string;
 };
 
 /**
  * Button that opens a license dialog when clicked.
  */
-const LicenseButton = ({ product, children, ...props }: LicenseButtonProps) => 
{
+const LicenseButton = ({ license, dialogTitle, children, ...props }: 
LicenseButtonProps) => {
   const [showEula, setShowEula] = useState(false);
 
   const open = () => setShowEula(true);
@@ -205,7 +227,7 @@
       <Button {...props} onClick={open}>
         {children}
       </Button>
-      {showEula && <LicenseDialog product={product} onClose={close} />}
+      {showEula && <LicenseDialog license={license} title={dialogTitle} 
onClose={close} />}
     </>
   );
 };
@@ -214,8 +236,16 @@
  * Props for EulaCheckbox component
  */
 type EulaCheckboxProps = {
-  /** The product whose license is being accepted */
+  /** The license being accepted */
+  license: License;
+  /** The product the license belongs to */
   product: Product;
+  /**
+   * Whether the label names the license instead of the product. Meant for
+   * products with several licenses, where each checkbox must tell which
+   * license it is about.
+   */
+  namesLicense?: boolean;
   /** Callback fired when checkbox state changes */
   onChange: (accepted: boolean) => void;
   /** Whether the checkbox is currently checked (i.e., license accepted) */
@@ -223,37 +253,71 @@
 };
 
 /**
- * Checkbox for accepting a product's license agreement.
- * Includes a link to view the full license text.
+ * Label naming the product, with a link to view its license.
  */
-const EulaCheckbox = ({ product, onChange, isChecked }: EulaCheckboxProps) => {
-  const [eulaTextStart, eulaTextLink, eulaTextEnd] = sprintf(
+const ProductEulaLabel = ({ license, product }: Pick<EulaCheckboxProps, 
"license" | "product">) => {
+  const [textStart, textLink, textEnd] = sprintf(
     // TRANSLATORS: Text used for the license acceptance checkbox. %s will be
     // replaced with the product name and the text in the square brackets [] is
     // used for the link to show the license, please keep the brackets.
     _("I have read and accept the [license] for %s"),
-    product?.name,
+    product.name,
   ).split(/[[\]]/);
 
   return (
     <>
-      <Checkbox
-        isChecked={isChecked}
-        onChange={(_, accepted) => onChange(accepted)}
-        id="license-acceptance"
-        label={
-          <>
-            {eulaTextStart}{" "}
-            <LicenseButton product={product} variant="link" isInline>
-              {eulaTextLink}
-            </LicenseButton>{" "}
-            {eulaTextEnd}
-          </>
-        }
-      />
+      {textStart}{" "}
+      <LicenseButton license={license} dialogTitle={product.name} 
variant="link" isInline>
+        {textLink}
+      </LicenseButton>{" "}
+      {textEnd}
     </>
   );
 };
+
+/**
+ * Label naming the license, which works as a link to view it.
+ */
+const LicenseEulaLabel = ({ license }: Pick<EulaCheckboxProps, "license">) => (
+  <Interpolate
+    // TRANSLATORS: Text used for accepting one of the several licenses of a
+    // product. %s will be replaced with the license name, which is also a link
+    // to show the license.
+    sentence={_("I have read and accept the %s")}
+  >
+    {() => (
+      <LicenseButton license={license} variant="link" isInline>
+        {license.name}
+      </LicenseButton>
+    )}
+  </Interpolate>
+);
+
+/**
+ * Checkbox for accepting a product license.
+ * Includes a link to view the full license text.
+ */
+const EulaCheckbox = ({
+  license,
+  product,
+  namesLicense = false,
+  onChange,
+  isChecked,
+}: EulaCheckboxProps) => (
+  <Checkbox
+    isChecked={isChecked}
+    onChange={(_, accepted) => onChange(accepted)}
+    id={`license-acceptance-${license.id}`}
+    label={
+      namesLicense ? (
+        <LicenseEulaLabel license={license} />
+      ) : (
+        <ProductEulaLabel license={license} product={product} />
+      )
+    }
+  />
+);
+
 /**
  * Props for ProductFormSubmitLabel component
  */
@@ -355,6 +419,8 @@
 type ProductFormProps = {
   /** List of all available products */
   products: Product[];
+  /** Licenses known by the system */
+  licenses?: License[];
   /** The product currently configured in the system */
   currentProduct?: Product;
   /** The id of the product mode currently configured in the system */
@@ -446,6 +512,7 @@
  */
 const ProductForm = ({
   products,
+  licenses,
   currentProduct,
   currentModeId,
   isSubmitted,
@@ -453,16 +520,22 @@
 }: ProductFormProps) => {
   const [selectedProduct, setSelectedProduct] = useState<Product>();
   const [selectedMode, setSelectedMode] = useState<Mode>();
-  const [eulaAccepted, setEulaAccepted] = useState(false);
-  const mountEulaCheckbox = selectedProduct && 
!isEmpty(selectedProduct.license);
+  const [acceptedLicenses, setAcceptedLicenses] = 
useState<License["id"][]>([]);
+  const selectedLicenses = productLicenses(selectedProduct, licenses);
+  const mountEulaCheckbox = !isEmpty(selectedLicenses);
+  const eulaAccepted = selectedLicenses.every((l) => 
acceptedLicenses.includes(l.id));
   const isSelectionDisabled =
     !selectedProduct ||
     isSubmitted ||
     (mountEulaCheckbox && !eulaAccepted) ||
     (!isEmpty(selectedProduct.modes) && !selectedMode);
 
+  const toggleLicenseAcceptance = (id: License["id"]) => {
+    setAcceptedLicenses((ids) => toggle(ids, id));
+  };
+
   const onProductSelectionChange = (product) => {
-    setEulaAccepted(false);
+    setAcceptedLicenses([]);
     setSelectedMode(undefined);
     setSelectedProduct(product);
   };
@@ -507,11 +580,18 @@
       <Stack hasGutter>
         {mountEulaCheckbox && (
           <StackItem>
-            <EulaCheckbox
-              product={selectedProduct}
-              isChecked={eulaAccepted}
-              onChange={setEulaAccepted}
-            />
+            <Stack hasGutter>
+              {selectedLicenses.map((license) => (
+                <EulaCheckbox
+                  key={license.id}
+                  license={license}
+                  product={selectedProduct}
+                  namesLicense={selectedLicenses.length > 1 && 
hasKnownName(license, licenses)}
+                  isChecked={acceptedLicenses.includes(license.id)}
+                  onChange={() => toggleLicenseAcceptance(license.id)}
+                />
+              ))}
+            </Stack>
           </StackItem>
         )}
         <StackItem>
@@ -561,16 +641,20 @@
   product?: Product;
   /** The selected mode */
   modeId?: string;
+  /** Licenses known by the system */
+  licenses?: License[];
 };
 
 /**
  * Card displaying information about the currently selected product.
  *
- * Shows product name, description, and a link to view the license if 
applicable.
+ * Shows product name, description, and links to view the licenses if 
applicable.
  */
-const CurrentProductInfo = ({ product, modeId }: CurrentProductInfoProps) => {
+const CurrentProductInfo = ({ product, modeId, licenses }: 
CurrentProductInfoProps) => {
   if (!product) return;
 
+  const currentLicenses = productLicenses(product, licenses);
+
   let mode: Mode;
   if (modeId) {
     mode = product.modes.find((m) => m.id === modeId);
@@ -597,11 +681,36 @@
             </>
           )}
 
-          {product.license && (
-            <LicenseButton product={product} variant="secondary" isInline>
+          {currentLicenses.length === 1 && (
+            <LicenseButton
+              license={currentLicenses[0]}
+              dialogTitle={product.name}
+              variant="secondary"
+              isInline
+            >
               {_("View license")}
             </LicenseButton>
           )}
+          {currentLicenses.length > 1 && (
+            <>
+              <Title headingLevel="h3">
+                {
+                  // TRANSLATORS: title of the list of licenses accepted for
+                  // the selected product
+                  _("Accepted licenses")
+                }
+              </Title>
+              <List>
+                {currentLicenses.map((license) => (
+                  <ListItem key={license.id}>
+                    <LicenseButton license={license} variant="link" isInline>
+                      {license.name}
+                    </LicenseButton>
+                  </ListItem>
+                ))}
+              </List>
+            </>
+          )}
         </Stack>
       </CardBody>
     </Card>
@@ -677,7 +786,7 @@
 const ProductSelectionContent = () => {
   const navigate = useNavigate();
   const product = useProduct();
-  const { products } = useSystem();
+  const { products, licenses } = useSystem();
   const currentProduct = useProductInfo();
   const [submittedSelection, setSubmmitedSelection] = useState<Product>();
   const [isSubmitted, setIsSubmmited] = useState(false);
@@ -733,6 +842,7 @@
           <GridItem sm={12} md={8} order={{ default: "1", md: "0" }}>
             <ProductForm
               products={products}
+              licenses={licenses}
               currentProduct={currentProduct}
               currentModeId={product?.mode}
               isSubmitted={isWaiting}
@@ -740,7 +850,13 @@
             />
           </GridItem>
           <GridItem sm={12} md={4} order={{ default: "0", md: "1" }}>
-            {!isWaiting && <CurrentProductInfo product={currentProduct} 
modeId={product?.mode} />}
+            {!isWaiting && (
+              <CurrentProductInfo
+                product={currentProduct}
+                modeId={product?.mode}
+                licenses={licenses}
+              />
+            )}
           </GridItem>
         </Grid>
       </Page.Content>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/questions/ExpandableTechnicalSection.test.tsx 
new/agama/src/components/questions/ExpandableTechnicalSection.test.tsx
--- old/agama/src/components/questions/ExpandableTechnicalSection.test.tsx      
1970-01-01 01:00:00.000000000 +0100
+++ new/agama/src/components/questions/ExpandableTechnicalSection.test.tsx      
2026-09-21 16:43:04.000000000 +0200
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import ExpandableTechnicalSection from 
"~/components/questions/ExpandableTechnicalSection";
+
+describe("ExpandableTechnicalSection", () => {
+  it("renders the technical details text", () => {
+    plainRender(<ExpandableTechnicalSection text="Error: Something went wrong" 
/>);
+
+    screen.getByText("Error: Something went wrong");
+  });
+
+  it("renders multi-line text correctly", () => {
+    const multiLineText = `Error: Failed to commit
+  Caused by:
+  - Storage device not found
+  - Invalid configuration`;
+
+    plainRender(<ExpandableTechnicalSection text={multiLineText} />);
+
+    screen.getByText(/Error: Failed to commit/);
+    screen.getByText(/Caused by:/);
+    screen.getByText(/Storage device not found/);
+    screen.getByText(/Invalid configuration/);
+  });
+
+  it("shows expand/collapse toggle buttons", () => {
+    plainRender(<ExpandableTechnicalSection text="Error details" />);
+
+    screen.getByText("Show technical details");
+  });
+
+  it("expands and collapses the section", async () => {
+    const { user } = plainRender(<ExpandableTechnicalSection text="Error 
details" />);
+
+    const toggleButton = screen.getByText("Show technical details");
+    await user.click(toggleButton);
+
+    screen.getByText("Hide technical details");
+  });
+
+  it("renders nothing when text is undefined", () => {
+    const { container } = plainRender(<ExpandableTechnicalSection 
text={undefined} />);
+
+    expect(container.firstChild).toBeNull();
+  });
+
+  it("renders nothing when text is empty", () => {
+    const { container } = plainRender(<ExpandableTechnicalSection text="" />);
+
+    expect(container.firstChild).toBeNull();
+  });
+});
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/questions/ExpandableTechnicalSection.tsx 
new/agama/src/components/questions/ExpandableTechnicalSection.tsx
--- old/agama/src/components/questions/ExpandableTechnicalSection.tsx   
1970-01-01 01:00:00.000000000 +0100
+++ new/agama/src/components/questions/ExpandableTechnicalSection.tsx   
2026-09-21 16:43:04.000000000 +0200
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { CodeBlock, ExpandableSection } from "@patternfly/react-core";
+import { NestedContent } from "~/components/core";
+import { _ } from "~/i18n";
+
+/**
+ * Component for rendering technical details in an expandable section
+ *
+ * @param text - the technical details to display
+ */
+export default function ExpandableTechnicalSection({ text }: { text?: string 
}): React.ReactNode {
+  if (!text) return null;
+
+  return (
+    <ExpandableSection
+      toggleTextExpanded={
+        /* TRANSLATORS: Clickable text to hide technical details at a popup 
window */
+        _("Hide technical details")
+      }
+      toggleTextCollapsed={
+        /* TRANSLATORS: Clickable text to show technical details at a popup 
window */
+        _("Show technical details")
+      }
+    >
+      <NestedContent>
+        <CodeBlock>
+          <pre className={"mh-60dvh"}>{text}</pre>
+        </CodeBlock>
+      </NestedContent>
+    </ExpandableSection>
+  );
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/questions/LoadConfigRetryQuestion.tsx 
new/agama/src/components/questions/LoadConfigRetryQuestion.tsx
--- old/agama/src/components/questions/LoadConfigRetryQuestion.tsx      
2026-09-02 11:36:48.000000000 +0200
+++ new/agama/src/components/questions/LoadConfigRetryQuestion.tsx      
2026-09-21 16:43:04.000000000 +0200
@@ -21,18 +21,11 @@
  */
 
 import React, { useState } from "react";
-import {
-  CodeBlock,
-  Content,
-  ExpandableSection,
-  Form,
-  FormGroup,
-  Stack,
-  TextInput,
-} from "@patternfly/react-core";
-import { NestedContent, Popup } from "~/components/core";
+import { Content, Form, FormGroup, Stack, TextInput } from 
"@patternfly/react-core";
+import { Popup } from "~/components/core";
 import Text from "~/components/core/Text";
 import QuestionActions from "~/components/questions/QuestionActions";
+import ExpandableTechnicalSection from 
"~/components/questions/ExpandableTechnicalSection";
 import { _ } from "~/i18n";
 import type { AnswerCallback, Question } from "~/model/question";
 
@@ -79,24 +72,7 @@
             {_("Make sure the location is correct and the configuration is 
valid.")}
           </Text>
         </Content>
-        {error && (
-          <ExpandableSection
-            toggleTextExpanded={
-              /* TRANSLATORS: Clickable text to hide technical details from 
popup window */
-              _("Hide technical details")
-            }
-            toggleTextCollapsed={
-              /* TRANSLATORS: Clickable text to show technical details at 
popup window */
-              _("Show technical details")
-            }
-          >
-            <NestedContent>
-              <CodeBlock>
-                <pre>{error}</pre>
-              </CodeBlock>
-            </NestedContent>
-          </ExpandableSection>
-        )}
+        <ExpandableTechnicalSection text={error} />
       </Stack>
       <Popup.Actions>
         <QuestionActions
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/questions/Questions.test.tsx 
new/agama/src/components/questions/Questions.test.tsx
--- old/agama/src/components/questions/Questions.test.tsx       2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/questions/Questions.test.tsx       2026-09-21 
16:43:04.000000000 +0200
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) [2022-2024] SUSE LLC
+ * Copyright (c) [2022-2026] SUSE LLC
  *
  * All Rights Reserved.
  *
@@ -41,6 +41,10 @@
   <div>LoadConfigRetryQuestion mock</div>
 ));
 
+jest.mock("~/components/questions/StorageCommitErrorQuestion", () => () => (
+  <div>StorageCommitErrorQuestion mock</div>
+));
+
 jest.mock("~/api", () => ({
   ...jest.requireActual("~/api"),
   patchQuestion: (...args) => mockPatchQuestionFn(...args),
@@ -95,6 +99,18 @@
   defaultAction: "no",
 };
 
+const storageCommitErrorQuestion: Question = {
+  id: 5,
+  class: "storageCommitError",
+  text: "Storage configuration could not be applied. Do you want to retry?",
+  field: { type: FieldType.None },
+  actions: [
+    { id: "retry", label: "Retry" },
+    { id: "cancel", label: "Cancel" },
+  ],
+  defaultAction: "cancel",
+};
+
 describe("Questions", () => {
   afterEach(() => {
     jest.restoreAllMocks();
@@ -173,4 +189,15 @@
       screen.getByText("LoadConfigRetryQuestion mock");
     });
   });
+
+  describe("when there is a storage commit error question pending", () => {
+    beforeEach(() => {
+      mockQuestions([storageCommitErrorQuestion]);
+    });
+
+    it("renders a StorageCommitErrorQuestion component", () => {
+      installerRender(<Questions />);
+      screen.getByText("StorageCommitErrorQuestion mock");
+    });
+  });
 });
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/components/questions/Questions.tsx 
new/agama/src/components/questions/Questions.tsx
--- old/agama/src/components/questions/Questions.tsx    2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/components/questions/Questions.tsx    2026-09-21 
16:43:04.000000000 +0200
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) [2022-2025] SUSE LLC
+ * Copyright (c) [2022-2026] SUSE LLC
  *
  * All Rights Reserved.
  *
@@ -28,6 +28,7 @@
 import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST";
 import RegistrationCertificateQuestion from 
"~/components/questions/RegistrationCertificateQuestion";
 import LoadConfigRetryQuestion from 
"~/components/questions/LoadConfigRetryQuestion";
+import StorageCommitErrorQuestion from 
"~/components/questions/StorageCommitErrorQuestion";
 import { useQuestions, useQuestionsChanges } from "~/hooks/model/question";
 import { patchQuestion } from "~/api";
 import { FieldType } from "~/model/question";
@@ -80,5 +81,10 @@
     QuestionComponent = LoadConfigRetryQuestion;
   }
 
+  // special popup for storage commit errors
+  if (questionClass === "storageCommitError") {
+    QuestionComponent = StorageCommitErrorQuestion;
+  }
+
   return <QuestionComponent question={currentQuestion} 
answerCallback={answerQuestion} />;
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx 
new/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx
--- old/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx      
1970-01-01 01:00:00.000000000 +0100
+++ new/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx      
2026-09-21 16:43:04.000000000 +0200
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import { Question, FieldType } from "~/model/question";
+import StorageCommitErrorQuestion from 
"~/components/questions/StorageCommitErrorQuestion";
+
+const questionWithDetails: Question = {
+  id: 1,
+  class: "storageCommitError",
+  text: "Storage configuration could not be applied. Do you want to retry?",
+  field: { type: FieldType.None },
+  data: {
+    details: `Failed to commit storage changes
+
+  Caused by:
+  0: Device /dev/sda not found
+  1: Invalid partition table`,
+  },
+  actions: [
+    { id: "retry", label: "Retry" },
+    { id: "cancel", label: "Cancel" },
+  ],
+  defaultAction: "cancel",
+};
+
+const questionWithoutDetails: Question = {
+  id: 2,
+  class: "storageCommitError",
+  text: "Storage configuration could not be applied. Do you want to retry?",
+  field: { type: FieldType.None },
+  actions: [
+    { id: "retry", label: "Retry" },
+    { id: "cancel", label: "Cancel" },
+  ],
+  defaultAction: "cancel",
+};
+
+const answerFn = jest.fn();
+
+describe("StorageCommitErrorQuestion", () => {
+  it("renders the question text", () => {
+    plainRender(
+      <StorageCommitErrorQuestion question={questionWithDetails} 
answerCallback={answerFn} />,
+    );
+
+    screen.getByText(questionWithDetails.text);
+  });
+
+  it("renders details from question", () => {
+    plainRender(
+      <StorageCommitErrorQuestion question={questionWithDetails} 
answerCallback={answerFn} />,
+    );
+
+    screen.getByText(/Failed to commit storage changes/);
+    screen.getByText(/Device \/dev\/sda not found/);
+  });
+
+  it("does not show expandable section when no details are present", () => {
+    plainRender(
+      <StorageCommitErrorQuestion question={questionWithoutDetails} 
answerCallback={answerFn} />,
+    );
+
+    expect(screen.queryByText("Show technical 
details")).not.toBeInTheDocument();
+  });
+
+  it("calls the callback with the selected action", async () => {
+    const { user } = plainRender(
+      <StorageCommitErrorQuestion question={questionWithDetails} 
answerCallback={answerFn} />,
+    );
+
+    const retryButton = await screen.findByRole("button", { name: "Retry" });
+    await user.click(retryButton);
+
+    expect(questionWithDetails.answer).toEqual(expect.objectContaining({ 
action: "retry" }));
+    expect(answerFn).toHaveBeenCalledWith(questionWithDetails);
+
+    const cancelButton = await screen.findByRole("button", { name: "Cancel" });
+    await user.click(cancelButton);
+
+    expect(questionWithDetails.answer).toEqual(expect.objectContaining({ 
action: "cancel" }));
+    expect(answerFn).toHaveBeenCalledWith(questionWithDetails);
+  });
+});
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/agama/src/components/questions/StorageCommitErrorQuestion.tsx 
new/agama/src/components/questions/StorageCommitErrorQuestion.tsx
--- old/agama/src/components/questions/StorageCommitErrorQuestion.tsx   
1970-01-01 01:00:00.000000000 +0100
+++ new/agama/src/components/questions/StorageCommitErrorQuestion.tsx   
2026-09-21 16:43:04.000000000 +0200
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { Content } from "@patternfly/react-core";
+import { Popup } from "~/components/core";
+import QuestionActions from "~/components/questions/QuestionActions";
+import ExpandableTechnicalSection from 
"~/components/questions/ExpandableTechnicalSection";
+import { _ } from "~/i18n";
+import type { AnswerCallback, Question } from "~/model/question";
+
+/**
+ * Component for rendering storage commit error questions
+ *
+ * @param question - the question to be answered
+ * @param answerCallback - the callback to be triggered on answer
+ */
+export default function StorageCommitErrorQuestion({
+  question,
+  answerCallback,
+}: {
+  question: Question;
+  answerCallback: AnswerCallback;
+}): React.ReactNode {
+  const actionCallback = (action: string) => {
+    question.answer = { action };
+    answerCallback(question);
+  };
+
+  return (
+    <Popup isOpen aria-label={_("Question")} variant="medium">
+      <Content component="p">{question.text}</Content>
+      <ExpandableTechnicalSection text={question.data?.details} />
+      <Popup.Actions>
+        <QuestionActions
+          actions={question.actions}
+          defaultAction={question.defaultAction}
+          actionCallback={actionCallback}
+        />
+      </Popup.Actions>
+    </Popup>
+  );
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/hooks/use-progress-tracking.test.ts 
new/agama/src/hooks/use-progress-tracking.test.ts
--- old/agama/src/hooks/use-progress-tracking.test.ts   2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/hooks/use-progress-tracking.test.ts   2026-09-21 
16:43:04.000000000 +0200
@@ -272,6 +272,11 @@
 
       expect(result.current.loading).toBe(true);
 
+      // startTracking is called when operation starts, not when it completes
+      await waitFor(() => {
+        expect(mockStartTracking).toHaveBeenCalledTimes(1);
+      });
+
       // Progress completes but task still running
       jest.advanceTimersByTime(1000);
       mockProgresses([]);
@@ -279,17 +284,16 @@
       rerender();
 
       expect(result.current.loading).toBe(true);
-      expect(mockStartTracking).not.toHaveBeenCalled();
+      // startTracking should only be called once, even though state changed
+      expect(mockStartTracking).toHaveBeenCalledTimes(1);
 
       // Task also completes
       jest.advanceTimersByTime(1000);
       mockTasks([]);
       rerender();
 
-      await waitFor(() => {
-        expect(mockStartTracking).toHaveBeenCalledTimes(1);
-      });
-
+      // Still only called once
+      expect(mockStartTracking).toHaveBeenCalledTimes(1);
       expect(result.current.loading).toBe(true);
 
       // Queries refetch
@@ -301,56 +305,56 @@
       });
     });
 
-    // FLAW: This test demonstrates that if a query is refetched BEFORE 
startTracking()
-    // is called (but after progress started), it will never be considered as 
refetched.
-    // The hook gets stuck in loading state because useTrackQueriesRefetch 
only detects
-    // queries with dataUpdatedAt > startedAt, where startedAt is captured when
-    // startTracking() is called, not when the progress originally started.
+    // This test verifies that startTracking() is called when progress starts,
+    // not when it completes. This ensures queries that refetch during the 
operation
+    // are properly detected as fresh by useTrackQueriesRefetch.
     //
-    // This can happen in real scenarios where:
-    // 1. Progress starts at T0
-    // 2. Query automatically refetches at T1 (due to invalidation or 
auto-refetch)
-    // 3. Progress completes at T2
-    // 4. startTracking() is called at T2, capturing startedAt = T2
-    // 5. The query's dataUpdatedAt (T1) < startedAt (T2), so it's never 
considered refetched
-    // 6. Hook waits forever for a refetch that already happened
-    it("FLAW: gets stuck in loading state when query refetches before 
startTracking is called", async () => {
+    // The trackingRequested state prevents multiple calls to startTracking() 
even
+    // if queries update during the operation, ensuring the startedAt timestamp
+    // captured by useTrackQueriesRefetch reflects when the operation actually 
began.
+    it("calls startTracking when progress starts, not when it completes", 
async () => {
       const { result, rerender } = renderHook(() => 
useProgressTracking("software"));
 
-      // Start progress at T0
+      // Before progress starts
+      expect(result.current.loading).toBe(false);
+      expect(mockStartTracking).not.toHaveBeenCalled();
+
+      // Progress starts at T0
       mockProgresses([fakeSoftwareProgress]);
       rerender();
 
+      // startTracking should be called immediately when progress starts
+      await waitFor(() => {
+        expect(mockStartTracking).toHaveBeenCalledTimes(1);
+      });
       expect(result.current.loading).toBe(true);
 
       // Query refetches during progress at T+1000ms (this would happen 
automatically
       // in real app due to query invalidation or refetchInterval)
       jest.advanceTimersByTime(1000);
-      // In reality, TanStack Query would update the query's dataUpdatedAt to 
T+1000
+      rerender();
+
+      // startTracking should NOT be called again despite query updates
+      expect(mockStartTracking).toHaveBeenCalledTimes(1);
 
       // Progress completes at T+2000ms
       jest.advanceTimersByTime(1000);
       mockProgresses([]);
       rerender();
 
-      // startTracking() is called now, capturing startedAt = T+2000
-      await waitFor(() => {
-        expect(mockStartTracking).toHaveBeenCalledTimes(1);
-      });
+      // startTracking should still only have been called once
+      expect(mockStartTracking).toHaveBeenCalledTimes(1);
 
-      // The query's dataUpdatedAt (T+1000) < startedAt (T+2000), so it won't 
be
-      // detected as refetched by useTrackQueriesRefetch. The hook will wait
-      // forever for a refetch that already happened.
-      //
-      // Expected behavior: loading should become false when the refetch from 
T+1000
-      // is detected, but it stays true indefinitely
+      // Loading stays true until refetch callback fires
       expect(result.current.loading).toBe(true);
 
-      // Even if time advances, loading stays true because no new refetch 
happens
-      jest.advanceTimersByTime(3000);
-      rerender();
+      // Queries finish refetching
+      jest.advanceTimersByTime(1000);
 
-      expect(result.current.loading).toBe(true);
+      await waitFor(() => {
+        mockRefetchCallback();
+        expect(result.current.loading).toBe(false);
+      });
     });
   });
 });
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/hooks/use-progress-tracking.ts 
new/agama/src/hooks/use-progress-tracking.ts
--- old/agama/src/hooks/use-progress-tracking.ts        2026-09-02 
11:36:48.000000000 +0200
+++ new/agama/src/hooks/use-progress-tracking.ts        2026-09-21 
16:43:04.000000000 +0200
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
  *
  * All Rights Reserved.
  *
@@ -101,9 +101,11 @@
   const progress = useProgress(scope);
   const status = useStatus();
   const [loading, setLoading] = useState(false);
+  const [tracking, setTracking] = useState(false);
+  const [trackingRequested, setTrackingRequested] = useState(false);
 
   const { startTracking } = useTrackQueriesRefetch(queryKeys, () => {
-    setLoading(false);
+    setTracking(false);
   });
 
   // Filter tasks by scope
@@ -117,14 +119,21 @@
   const allFinished = progressesFinished && tasksFinished;
 
   useEffect(() => {
-    if (allFinished && loading) {
+    if (!allFinished && !trackingRequested) {
+      setTrackingRequested(true);
+      setTracking(true);
       startTracking();
     }
-  }, [allFinished, startTracking, loading]);
+  }, [allFinished, setTrackingRequested, startTracking, trackingRequested]);
+
+  if (allFinished && !tracking && loading) {
+    setTrackingRequested(false);
+    setLoading(false);
+  }
 
   // Enter the loading state as soon as an operation is detected. Setting state
   // during render (instead of in an effect) avoids a flash of non-loading UI.
-  if (!allFinished && !loading) {
+  if (!loading && (!allFinished || tracking)) {
     setLoading(true);
   }
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/src/model/system.ts 
new/agama/src/model/system.ts
--- old/agama/src/model/system.ts       2026-09-02 11:36:48.000000000 +0200
+++ new/agama/src/model/system.ts       2026-09-21 16:43:04.000000000 +0200
@@ -37,6 +37,7 @@
   l10n?: L10n.System;
   network?: Network.System;
   products?: Product[];
+  licenses?: License[];
   software?: Software.System;
   storage?: Storage.System;
   iscsi?: ISCSI.System;
@@ -66,14 +67,21 @@
   icon?: string;
   /** If product is registrable or not */
   registration: boolean;
-  /** The product license id, if any */
-  license?: string;
+  /** Ids of the licenses the user must accept to install the product */
+  licenses?: string[];
   /** Desktop selection mode; absent when the product does not declare it */
   desktopSelection?: DesktopSelection;
   /** Available modes for this product (names and descriptions are translated 
to current UI language) */
   modes: Mode[];
 };
 
+type License = {
+  /** License ID (e.g., "license.final") */
+  id: string;
+  /** License name, in the current system language */
+  name: string;
+};
+
 type Mode = {
   /** Mode ID (e.g., "traditional") */
   id: string;
@@ -86,6 +94,8 @@
 type LicenseContent = {
   /** License ID (e.g., "license.sle") */
   id: string;
+  /** License name */
+  name: string;
   /** License body */
   body: string;
   /** License language (e.g., "en-US") */
@@ -96,6 +106,7 @@
   System,
   Product,
   DesktopSelection,
+  License,
   LicenseContent,
   L10n,
   Hardware,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/agama/webpack.config.js new/agama/webpack.config.js
--- old/agama/webpack.config.js 2026-09-02 11:36:48.000000000 +0200
+++ new/agama/webpack.config.js 2026-09-21 16:43:04.000000000 +0200
@@ -206,6 +206,12 @@
               sassOptions: {
                 includePaths: ["node_modules"],
                 outputStyle: production ? "compressed" : undefined,
+                // Never emit a @charset or a byte-order mark. Compressed 
output
+                // marks a non-ASCII stylesheet with a BOM, and the extracted
+                // stylesheets are concatenated into a single file, so that BOM
+                // would land mid-file, where it is part of the next selector
+                // rather than whitespace and silently kills that rule.
+                charset: false,
                 silenceDeprecations: ["global-builtin", "if-function"],
               },
             },

++++++ agama.obsinfo ++++++
--- /var/tmp/diff_new_pack.okt1Fk/_old  2026-09-22 15:49:06.697973564 +0200
+++ /var/tmp/diff_new_pack.okt1Fk/_new  2026-09-22 15:49:06.703973813 +0200
@@ -1,5 +1,5 @@
 name: agama
-version: 24+0.a836cced5
-mtime: 1788341808
-commit: a836cced585e55fdf43cbbda2c516a143ab80624
+version: 24+79.19b44f04f
+mtime: 1790001784
+commit: 19b44f04f10bb7a865c9edd2abf7ea6cb9b012d4
 

++++++ node_modules.sums ++++++
++++ 1740 lines (skipped)
++++ between /work/SRC/openSUSE:Factory/agama-web-ui/node_modules.sums
++++ and /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539/node_modules.sums

Reply via email to