diff --git a/web/src/App.tsx b/web/src/App.tsx index 1075dcdd46..676de66677 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,7 +29,6 @@ import { useIssuesChanges } from "~/hooks/model/issue"; import { useProduct } from "~/hooks/model/config"; import { ROOT } from "~/routes/paths"; import { useQueryClient } from "@tanstack/react-query"; -import AlertOutOfSync from "~/components/core/AlertOutOfSync"; /** * Content guard and flow control component. @@ -59,13 +58,7 @@ const Content = () => { return ; } - return ( - <> - {/* So far, only the storage backend is able to detect external changes.*/} - - - - ); + return ; }; /** diff --git a/web/src/components/core/AlertOutOfSync.test.tsx b/web/src/components/core/AlertOutOfSync.test.tsx deleted file mode 100644 index b607a3cd61..0000000000 --- a/web/src/components/core/AlertOutOfSync.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (c) [2025] 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, { act } from "react"; -import { screen, within } from "@testing-library/dom"; -import { installerRender, plainRender } from "~/test-utils"; -import AlertOutOfSync from "./AlertOutOfSync"; - -const mockOnEvent = jest.fn(); -const mockReload = jest.fn(); - -const mockClient = { - id: "current-client", - isConnected: jest.fn().mockResolvedValue(true), - isRecoverable: jest.fn(), - onConnect: jest.fn(), - onClose: jest.fn(), - onError: jest.fn(), - onEvent: mockOnEvent, -}; - -let consoleErrorSpy: jest.SpyInstance; - -jest.mock("~/context/installer", () => ({ - ...jest.requireActual("~/context/installer"), - useInstallerClient: () => mockClient, -})); -jest.mock("~/utils", () => ({ - ...jest.requireActual("~/utils"), - locationReload: () => mockReload(), -})); - -describe("AlertOutOfSync", () => { - beforeAll(() => { - consoleErrorSpy = jest.spyOn(console, "error"); - consoleErrorSpy.mockImplementation(); - }); - - it("renders nothing if scope is missing", () => { - // @ts-expect-error: scope is required prop - const { container } = plainRender(); - expect(container).toBeEmptyDOMElement(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("must receive a value for `scope`"), - ); - }); - - it("renders nothing if scope empty", () => { - const { container } = plainRender(); - expect(container).toBeEmptyDOMElement(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("must receive a value for `scope`"), - ); - }); - - it("shows alert on matching changes event from a different client for subscribed scope", () => { - let eventCallback; - mockClient.onEvent.mockImplementation((cb) => { - eventCallback = cb; - return () => {}; - }); - - installerRender(); - - // Should not render the alert initially - expect(screen.queryByRole("dialog")).toBeNull(); - - // Simulate a change event for a different scope - act(() => { - eventCallback({ type: "NotWatchedChanged", clientId: "other-client" }); - }); - - expect(screen.queryByRole("dialog")).toBeNull(); - - // Simulate a change event for the subscribed scope, from current client - act(() => { - eventCallback({ type: "WatchedChanged", clientId: "current-client" }); - }); - - expect(screen.queryByRole("dialog")).toBeNull(); - - // Simulate a change event for the subscribed scope, from different client - act(() => { - eventCallback({ type: "WatchedChanged", clientId: "other-client" }); - }); - - const dialog = screen.getByRole("dialog", { name: "Configuration out of sync" }); - within(dialog).getByRole("button", { name: "Reload now" }); - }); - - it("dismisses automatically the alert on matching changes event from current client for subscribed scope", () => { - let eventCallback; - mockClient.onEvent.mockImplementation((cb) => { - eventCallback = cb; - return () => {}; - }); - - installerRender(); - - // Simulate a change event for the subscribed scope, from different client - act(() => { - eventCallback({ type: "WatchedChanged", clientId: "other-client" }); - }); - - screen.getByRole("dialog", { name: "Configuration out of sync" }); - - // Simulate a change event for the subscribed scope, from current client - act(() => { - eventCallback({ type: "WatchedChanged", clientId: "current-client" }); - }); - - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("triggers a location relaod when clicking on `Reload now`", async () => { - let eventCallback; - mockClient.onEvent.mockImplementation((cb) => { - eventCallback = cb; - return () => {}; - }); - - const { user } = installerRender(); - - // Simulate a change event for the subscribed scope, from different client - act(() => { - eventCallback({ type: "WatchedChanged", clientId: "other-client" }); - }); - - const reloadButton = screen.getByRole("button", { name: "Reload now" }); - await user.click(reloadButton); - expect(mockReload).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/core/AlertOutOfSync.tsx b/web/src/components/core/AlertOutOfSync.tsx deleted file mode 100644 index 29e34fffeb..0000000000 --- a/web/src/components/core/AlertOutOfSync.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) [2025] 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, { useEffect, useState } from "react"; -import { Content } from "@patternfly/react-core"; -import { useInstallerClient } from "~/context/installer"; -import { isEmpty } from "radashi"; -import { _ } from "~/i18n"; -import { locationReload } from "~/utils"; -import Popup, { PopupProps } from "~/components/core/Popup"; - -type AlertOutOfSyncProps = Partial> & { - /** - * The scope to listen for change events on (e.g., `SoftwareProposal`, - * `L10nConfig`). - */ - scope: string; -}; - -/** - * Reactive alert shown when the configuration for a given scope has been changed externally. - * - * It warns that the interface may be out of sync and forces reloading before continuing to avoid - * issues and data loss. - * - * It works by listening for "Changed" events on the specified scope and displays a popup if the - * event originates from a different client (based on client ID). - * - * @example - * ```tsx - * - * ``` - */ -export default function AlertOutOfSync({ scope, ...alertProps }: AlertOutOfSyncProps) { - const client = useInstallerClient(); - const [active, setActive] = useState(false); - const missingScope = isEmpty(scope); - - useEffect(() => { - if (missingScope) return; - - return client.onEvent((event) => { - event.type === `${scope}Changed` && setActive(event.clientId !== client.id); - }); - }); - - if (missingScope) { - console.error("AlertOutOfSync must receive a value for `scope` prop"); - return; - } - - const title = _("Configuration out of sync"); - - return ( - - {_("The configuration has been updated externally.")} - - {_("Reloading is required to get the latest data and avoid issues or data loss.")} - - - {_("Reload now")} - - - ); -} diff --git a/web/src/components/core/ChangeProductOption.test.tsx b/web/src/components/core/ChangeProductOption.test.tsx index 36e4c72735..312c3a4d79 100644 --- a/web/src/components/core/ChangeProductOption.test.tsx +++ b/web/src/components/core/ChangeProductOption.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { useSystem } from "~/hooks/api"; +import { useSystem } from "~/hooks/model/system"; import { PRODUCT as PATHS } from "~/routes/paths"; import { Product } from "~/types/software"; import ChangeProductOption from "./ChangeProductOption"; diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index 7b02a395d0..e669546911 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -25,19 +25,20 @@ import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import { InstallButton } from "~/components/core"; import { PRODUCT, ROOT } from "~/routes/paths"; -import { Issue, IssueSeverity, IssueSource } from "~/model/issue"; +import type { Issue } from "~/model/issue"; const mockStartInstallationFn = jest.fn(); -let mockIssuesList: Issue[]; -jest.mock("~/api/manager", () => ({ - ...jest.requireActual("~/api/manager"), +jest.mock("~/model/manager", () => ({ + ...jest.requireActual("~/model/manager"), startInstallation: () => mockStartInstallationFn(), })); -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useAllIssues: () => mockIssuesList, +const mockIssues = jest.fn(); + +jest.mock("~/hooks/model/issue", () => ({ + ...jest.requireActual("~/hooks/model/issue"), + useIssues: () => mockIssues(), })); const clickInstallButton = async () => { @@ -52,16 +53,14 @@ const clickInstallButton = async () => { describe("InstallButton", () => { describe("when there are installation issues", () => { beforeEach(() => { - mockIssuesList = [ + mockIssues.mockReturnValue([ { description: "Fake Issue", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Fake Issue details", scope: "product", }, - ]; + ] as Issue[]); }); it("renders additional information to warn users about found problems", async () => { @@ -86,7 +85,7 @@ describe("InstallButton", () => { describe("when there are not installation issues", () => { beforeEach(() => { - mockIssuesList = []; + mockIssues.mockReturnValue([]); }); it("renders the button without any additional information", async () => { @@ -130,16 +129,14 @@ describe("InstallButton", () => { describe("when there are only non-critical issues", () => { beforeEach(() => { - mockIssuesList = [ + mockIssues.mockReturnValue([ { description: "Fake warning", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Warn, + class: "generic", details: "Fake Issue details", scope: "product", }, - ]; + ] as Issue[]); }); it("renders the button without any additional information", async () => { diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index d7ab6f12d9..84ae904701 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -27,7 +27,7 @@ import { useSystem } from "~/hooks/model/system"; import { useProduct } from "~/hooks/model/config"; import { Product } from "~/types/software"; import { Keymap, Locale } from "~/model/system/l10n"; -import { Progress, State } from "~/model/status"; +import { Progress, Stage } from "~/model/status"; import { System } from "~/model/system/network"; import * as utils from "~/utils"; import { PRODUCT, ROOT } from "~/routes/paths"; @@ -68,7 +68,7 @@ const mockChangeUIKeymap = jest.fn(); const mockChangeUILanguage = jest.fn(); const mockPatchConfigFn = jest.fn(); const mockConfigureL10nActionFn = jest.fn(); -const mockStateFn: jest.Mock = jest.fn(); +const mockStateFn: jest.Mock = jest.fn(); const mockProgressesFn: jest.Mock = jest.fn(); const mockSelectedProductFn: jest.Mock = jest.fn(); @@ -85,7 +85,7 @@ jest.mock("~/hooks/api", () => ({ network, }), useStatus: (): ReturnType => ({ - state: mockStateFn(), + stage: mockStateFn(), progresses: mockProgressesFn(), }), useProduct: (): ReturnType => mockSelectedProductFn(), diff --git a/web/src/components/core/IssuesAlert.test.tsx b/web/src/components/core/IssuesAlert.test.tsx index 683470877c..8b18ace6c0 100644 --- a/web/src/components/core/IssuesAlert.test.tsx +++ b/web/src/components/core/IssuesAlert.test.tsx @@ -24,16 +24,14 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { IssuesAlert } from "~/components/core"; -import { Issue, IssueSeverity, IssueSource } from "~/model/issue"; import { SOFTWARE } from "~/routes/paths"; +import type { Issue } from "~/model/issue"; describe("IssueAlert", () => { it("renders a list of issues", () => { const issue: Issue = { description: "A generic issue", - source: IssueSource.Config, - severity: IssueSeverity.Error, - kind: "generic", + class: "generic", scope: "software", }; installerRender(); @@ -43,9 +41,7 @@ describe("IssueAlert", () => { it("renders a link to conflict resolution when there is a 'solver' issue", () => { const issue: Issue = { description: "Conflicts found", - source: IssueSource.Config, - severity: IssueSeverity.Error, - kind: "solver", + class: "solver", scope: "software", }; installerRender(); diff --git a/web/src/components/core/IssuesDrawer.test.tsx b/web/src/components/core/IssuesDrawer.test.tsx index 6527427610..2bb1a64b32 100644 --- a/web/src/components/core/IssuesDrawer.test.tsx +++ b/web/src/components/core/IssuesDrawer.test.tsx @@ -24,16 +24,16 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { InstallationPhase } from "~/types/status"; -import { Issue, IssueSeverity, IssueSource } from "~/model/issue"; import IssuesDrawer from "./IssuesDrawer"; +import type { Issue } from "~/model/issue"; let phase = InstallationPhase.Config; -let mockIssuesList: Issue[]; +const mockIssues = jest.fn(); const onCloseFn = jest.fn(); -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useAllIssues: () => mockIssuesList, +jest.mock("~/model/issue", () => ({ + ...jest.requireActual("~/model/issues"), + useIssues: (): Issue[] => mockIssues(), })); jest.mock("~/queries/status", () => ({ @@ -51,7 +51,7 @@ const itRendersNothing = () => describe("IssuesDrawer", () => { describe("when there are no installation issues", () => { beforeEach(() => { - mockIssuesList = []; + mockIssues.mockReturnValue([]); }); itRendersNothing(); @@ -59,16 +59,14 @@ describe("IssuesDrawer", () => { describe("when there are non-critical issues", () => { beforeEach(() => { - mockIssuesList = [ + mockIssues.mockReturnValue([ { description: "Registration Fake Warning", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Warn, + class: "generic", details: "Registration Fake Issue details", scope: "product", }, - ]; + ] as Issue[]); }); itRendersNothing(); @@ -76,48 +74,38 @@ describe("IssuesDrawer", () => { describe("when there are installation issues", () => { beforeEach(() => { - mockIssuesList = [ + mockIssues.mockReturnValue([ { description: "Registration Fake Issue", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Registration Fake Issue details", scope: "product", }, { description: "Software Fake Issue", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Software Fake Issue details", scope: "software", }, { description: "Storage Fake Issue 1", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Storage Fake Issue 1 details", scope: "storage", }, { description: "Storage Fake Issue 2", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Storage Fake Issue 2 details", scope: "storage", }, { description: "Users Fake Issue", - kind: "generic", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, + class: "generic", details: "Users Fake Issue details", scope: "users", }, - ]; + ] as Issue[]); }); it("renders the drawer with categorized issues linking to their scope", async () => { diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index f13243696f..78a7c47b8f 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -29,7 +29,7 @@ import { _ } from "~/i18n"; import Page from "./Page"; let consoleErrorSpy: jest.SpyInstance; -let mockStartTracking: jest.Mock = jest.fn(); +const mockStartTracking: jest.Mock = jest.fn(); jest.mock("~/hooks/use-track-queries-refetch", () => ({ __esModule: true, diff --git a/web/src/components/core/ProgressBackdrop.test.tsx b/web/src/components/core/ProgressBackdrop.test.tsx index f92e57796c..7fbe1cac82 100644 --- a/web/src/components/core/ProgressBackdrop.test.tsx +++ b/web/src/components/core/ProgressBackdrop.test.tsx @@ -25,10 +25,9 @@ import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockProgresses } from "~/test-utils"; import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; -import { _ } from "~/i18n"; import ProgressBackdrop from "./ProgressBackdrop"; -let mockStartTracking: jest.Mock = jest.fn(); +const mockStartTracking: jest.Mock = jest.fn(); jest.mock("~/hooks/use-track-queries-refetch", () => ({ __esModule: true, @@ -146,7 +145,7 @@ describe("ProgressBackdrop", () => { const backdrop = screen.getByRole("alert", { name: /Calculating proposal/ }); - rerender(Content); + rerender(); // Should show refreshing message await waitFor(() => { diff --git a/web/src/components/core/SelectableDataTable.tsx b/web/src/components/core/SelectableDataTable.tsx index 39b32a6d00..f76db91409 100644 --- a/web/src/components/core/SelectableDataTable.tsx +++ b/web/src/components/core/SelectableDataTable.tsx @@ -90,8 +90,7 @@ export type SelectableDataTableColumn = { * If defined, marks the column as sortable and specifies the key used for * sorting. */ - sortingKey?: string; - + sortingKey?: string | ((item: object) => string | number); /** * A space-separated string of additional CSS class names to apply to the column's cells. * Useful for custom styling or conditional formatting. diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index a8cc908c58..ddb1702d4f 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -45,12 +45,6 @@ jest.mock("~/hooks/api/system", () => ({ useSystem: () => jest.fn(), })); -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), - useSystem: () => mockSystem, - useConnections: () => mockSystem.connections, -})); - const mockConnection = new Connection("Visible Network", { wireless: new Wireless({ ssid: "Visible Network" }), }); @@ -66,6 +60,12 @@ const mockSystem = { }, }; +jest.mock("~/hooks/api/system/network", () => ({ + ...jest.requireActual("~/hooks/api/system/network"), + useSystem: () => mockSystem, + useConnections: () => mockSystem.connections, +})); + const networkMock = { ssid: "Visible Network", hidden: false, diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx index 9bb7f58cef..b25460f8c7 100644 --- a/web/src/components/overview/L10nSection.test.tsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -24,11 +24,11 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { L10nSection } from "~/components/overview"; -import { Locale } from "~/model/system"; +import { L10n } from "~/model/system"; -const locales: Locale[] = [ - { id: "en_US.UTF-8", name: "English", territory: "United States" }, - { id: "de_DE.UTF-8", name: "German", territory: "Germany" }, +const locales: L10n.Locale[] = [ + { id: "en_US.UTF-8", language: "English", territory: "United States" }, + { id: "de_DE.UTF-8", language: "German", territory: "Germany" }, ]; jest.mock("~/queries/system", () => ({ diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index ac68e23fe8..581d1d26b3 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,7 +24,6 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; -import { IssueSeverity, IssueSource } from "~/model/issue"; let mockModel = { drives: [], @@ -249,8 +248,6 @@ describe("when there is no configuration model (unsupported features)", () => { description: "System error", kind: "storage", details: "", - source: IssueSource.System, - severity: IssueSeverity.Error, scope: "storage", }, ]; diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index d443576c9f..8009ff92e3 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -27,7 +27,7 @@ import { AnswerCallback, Question, FieldType } from "~/model/question"; import { InstallationPhase } from "~/types/status"; import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; -import { Locale, Keymap } from "~/model/system"; +import type { Locale, Keymap } from "~/model/system/l10n"; let question: Question; const questionMock: Question = { @@ -51,12 +51,12 @@ const tumbleweed: Product = { }; const locales: Locale[] = [ - { id: "en_US.UTF-8", name: "English", territory: "United States" }, - { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, + { id: "en_US.UTF-8", language: "English", territory: "United States" }, + { id: "es_ES.UTF-8", language: "Spanish", territory: "Spain" }, ]; const keymaps: Keymap[] = [ - { id: "us", name: "English" }, - { id: "es", name: "Spanish" }, + { id: "us", description: "English" }, + { id: "es", description: "Spanish" }, ]; jest.mock("~/queries/system", () => ({ diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 08df928200..d505d4ea60 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -27,7 +27,7 @@ import { Question, FieldType } from "~/model/question"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; -import { Locale, Keymap } from "~/model/system"; +import { Locale, Keymap } from "~/model/system/l10n"; const answerFn = jest.fn(); const question: Question = { @@ -51,13 +51,13 @@ const tumbleweed: Product = { }; const locales: Locale[] = [ - { id: "en_US.UTF-8", name: "English", territory: "United States" }, - { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, + { id: "en_US.UTF-8", language: "English", territory: "United States" }, + { id: "es_ES.UTF-8", language: "Spanish", territory: "Spain" }, ]; const keymaps: Keymap[] = [ - { id: "us", name: "English" }, - { id: "es", name: "Spanish" }, + { id: "us", description: "English" }, + { id: "es", description: "Spanish" }, ]; jest.mock("~/queries/status", () => ({ diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 817c562293..42fa38644a 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -24,9 +24,6 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import BootSelection from "./BootSelection"; -import { System } from "~/model/system"; -import { Config } from "~/openapi/storage/config-model"; -import { putStorageModel } from "~/api"; // FIXME: drop this mock once a better solution for dealing with // ProductRegistrationAlert, which uses a query with suspense, @@ -34,43 +31,27 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -const network = { - connections: [], - devices: [], - state: { - connectivity: true, - copyNetwork: true, - networkingEnabled: true, - wirelessEnabled: true, +const mockDevices = [ + { + sid: 1, + class: "drive", + name: "/dev/sda", }, - accessPoints: [], -}; - -const system = (): System => ({ - network, - storage: { - devices: [ - { - sid: 1, - class: "drive", - name: "/dev/sda", - }, - { - sid: 2, - class: "drive", - name: "/dev/sdb", - }, - { - sid: 3, - class: "drive", - name: "/dev/sdc", - }, - ], - candidateDrives: [1, 2], + { + sid: 2, + class: "drive", + name: "/dev/sdb", }, -}); + { + sid: 3, + class: "drive", + name: "/dev/sdc", + }, +]; + +const mockCandidateDevices = [mockDevices[0], mockDevices[1]]; -const storageModel = (): Config => ({ +const mockConfigModel = { boot: { configure: true, device: { @@ -78,21 +59,34 @@ const storageModel = (): Config => ({ name: "/dev/sda", }, }, -}); + drives: [], + mdRaids: [], +}; -const getSystem = jest.fn(); -const getStorageModel = jest.fn(); +const mockUseDevices = jest.fn(); +const mockUseCandidateDevices = jest.fn(); +const mockUseConfigModel = jest.fn(); +const mockSetBootDevice = jest.fn(); +const mockSetDefaultBootDevice = jest.fn(); +const mockDisableBootConfig = jest.fn(); -jest.mock("~/api", () => ({ - ...jest.requireActual("~/api"), - getSystem: () => getSystem(), - getStorageModel: () => getStorageModel(), - putStorageModel: jest.fn(), +jest.mock("~/hooks/model/system/storage", () => ({ + useDevices: () => mockUseDevices(), + useCandidateDevices: () => mockUseCandidateDevices(), +})); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + useConfigModel: () => mockUseConfigModel(), + useSetBootDevice: () => mockSetBootDevice, + useSetDefaultBootDevice: () => mockSetDefaultBootDevice, + useDisableBoot: () => mockDisableBootConfig, })); beforeEach(() => { - getSystem.mockResolvedValue(system()); - getStorageModel.mockResolvedValue(storageModel()); + jest.clearAllMocks(); + mockUseDevices.mockReturnValue(mockDevices); + mockUseCandidateDevices.mockReturnValue(mockCandidateDevices); + mockUseConfigModel.mockReturnValue(mockConfigModel); }); describe("BootSelectionDialog", () => { @@ -137,9 +131,16 @@ describe("BootSelectionDialog", () => { describe("if the current value is set to boot from a selected disk", () => { beforeEach(() => { - const model = storageModel(); - model.boot.device.default = false; - getStorageModel.mockResolvedValue(model); + mockUseConfigModel.mockReturnValue({ + ...mockConfigModel, + boot: { + configure: true, + device: { + default: false, + name: "/dev/sda", + }, + }, + }); }); it("selects 'Select a disk' option by default", async () => { @@ -155,9 +156,10 @@ describe("BootSelectionDialog", () => { describe("if the current value is set to not configure boot", () => { beforeEach(() => { - const model = storageModel(); - model.boot.configure = false; - getStorageModel.mockResolvedValue(model); + mockUseConfigModel.mockReturnValue({ + ...mockConfigModel, + boot: { configure: false }, + }); }); it("selects 'Do not configure' option by default", async () => { @@ -173,10 +175,17 @@ describe("BootSelectionDialog", () => { describe("if the current boot device is not a candidate device", () => { beforeEach(() => { - const model = storageModel(); - model.boot.device.name = "/dev/sdc"; - model.drives = [{ name: "/dev/sdc" }]; - getStorageModel.mockResolvedValue(model); + mockUseConfigModel.mockReturnValue({ + ...mockConfigModel, + boot: { + configure: true, + device: { + default: false, + name: "/dev/sdc", + }, + }, + drives: [{ name: "/dev/sdc" }], + }); }); it("offers the current boot device as an option", async () => { @@ -193,7 +202,9 @@ describe("BootSelectionDialog", () => { await waitFor(() => expect(diskSelector()).toBeInTheDocument()); const cancel = screen.getByRole("link", { name: "Cancel" }); await user.click(cancel); - expect(putStorageModel).not.toHaveBeenCalled(); + expect(mockSetBootDevice).not.toHaveBeenCalled(); + expect(mockSetDefaultBootDevice).not.toHaveBeenCalled(); + expect(mockDisableBootConfig).not.toHaveBeenCalled(); }); it("applies the expected boot options when 'Automatic' is selected", async () => { @@ -202,16 +213,9 @@ describe("BootSelectionDialog", () => { await user.click(automaticOption()); const accept = screen.getByRole("button", { name: "Accept" }); await user.click(accept); - expect(putStorageModel).toHaveBeenCalledWith( - expect.objectContaining({ - boot: { - configure: true, - device: { - default: true, - }, - }, - }), - ); + expect(mockSetDefaultBootDevice).toHaveBeenCalled(); + expect(mockSetBootDevice).not.toHaveBeenCalled(); + expect(mockDisableBootConfig).not.toHaveBeenCalled(); }); it("applies the expected boot options when a disk is selected", async () => { @@ -223,17 +227,9 @@ describe("BootSelectionDialog", () => { await user.selectOptions(selector, sdbOption); const accept = screen.getByRole("button", { name: "Accept" }); await user.click(accept); - expect(putStorageModel).toHaveBeenCalledWith( - expect.objectContaining({ - boot: { - configure: true, - device: { - default: false, - name: "/dev/sdb", - }, - }, - }), - ); + expect(mockSetBootDevice).toHaveBeenCalledWith("/dev/sdb"); + expect(mockSetDefaultBootDevice).not.toHaveBeenCalled(); + expect(mockDisableBootConfig).not.toHaveBeenCalled(); }); it("applies the expected boot options when 'No configure' is selected", async () => { @@ -242,12 +238,8 @@ describe("BootSelectionDialog", () => { await user.click(notConfigureOption()); const accept = screen.getByRole("button", { name: "Accept" }); await user.click(accept); - expect(putStorageModel).toHaveBeenCalledWith( - expect.objectContaining({ - boot: { - configure: false, - }, - }), - ); + expect(mockDisableBootConfig).toHaveBeenCalled(); + expect(mockSetBootDevice).not.toHaveBeenCalled(); + expect(mockSetDefaultBootDevice).not.toHaveBeenCalled(); }); }); diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx index 3d1b7c52c6..e68c004a57 100644 --- a/web/src/components/storage/ConfigEditor.test.tsx +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -24,32 +24,19 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import ConfigEditor from "~/components/storage/ConfigEditor"; -import { StorageDevice } from "~/storage"; -import { apiModel } from "~/api/storage/types"; - -const disk: StorageDevice = { - sid: 60, - type: "disk", - isDrive: true, - description: "", - vendor: "Seagate", - model: "Unknown", - driver: ["ahci", "mmcblk"], - bus: "IDE", - name: "/dev/vda", - size: 1e6, -}; +import type { ConfigModel } from "~/model/storage/config-model"; + +const mockUseModel = jest.fn(); +const mockUseReset = jest.fn(); -const mockUseDevices = jest.fn(); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => mockUseDevices(), +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => mockUseModel(), })); -const mockUseApiModel = jest.fn(); -jest.mock("~/hooks/storage/api-model", () => ({ - ...jest.requireActual("~/hooks/storage/api-model"), - useApiModel: () => mockUseApiModel(), +jest.mock("~/hooks/model/config/storage", () => ({ + ...jest.requireActual("~/hooks/model/config/storage"), + useReset: () => mockUseReset(), })); jest.mock("./DriveEditor", () => () =>
drive editor
); @@ -57,37 +44,33 @@ jest.mock("./MdRaidEditor", () => () =>
raid editor
); jest.mock("./VolumeGroupEditor", () => () =>
volume group editor
); jest.mock("./ConfigureDeviceMenu", () => () =>
add device
); -const hasDrives: apiModel.Config = { +const hasDrives: ConfigModel.Config = { drives: [{ name: "/dev/vda" }], mdRaids: [], volumeGroups: [], }; -const hasVolumeGroups: apiModel.Config = { +const hasVolumeGroups: ConfigModel.Config = { drives: [], mdRaids: [], volumeGroups: [{ vgName: "/dev/system" }], }; -const hasBoth: apiModel.Config = { +const hasBoth: ConfigModel.Config = { drives: [{ name: "/dev/vda" }], mdRaids: [], volumeGroups: [{ vgName: "/dev/system" }], }; -const hasNothing: apiModel.Config = { +const hasNothing: ConfigModel.Config = { drives: [], mdRaids: [], volumeGroups: [], }; -beforeEach(() => { - mockUseDevices.mockReturnValue([disk]); -}); - describe("when no drive is used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasVolumeGroups); + mockUseModel.mockReturnValue(hasVolumeGroups); }); it("does not render the drive editor", () => { @@ -98,7 +81,7 @@ describe("when no drive is used for installation", () => { describe("when a drive is used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasDrives); + mockUseModel.mockReturnValue(hasDrives); }); it("renders the drive editor", () => { @@ -109,7 +92,7 @@ describe("when a drive is used for installation", () => { describe("when no volume group is used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasDrives); + mockUseModel.mockReturnValue(hasDrives); }); it("does not render the volume group editor", () => { @@ -120,7 +103,7 @@ describe("when no volume group is used for installation", () => { describe("when a volume group is used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasVolumeGroups); + mockUseModel.mockReturnValue(hasVolumeGroups); }); it("renders the volume group editor", () => { @@ -131,7 +114,7 @@ describe("when a volume group is used for installation", () => { describe("when both a drive and volume group are used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasBoth); + mockUseModel.mockReturnValue(hasBoth); }); it("renders a volume group editor followed by drive editor", () => { @@ -147,7 +130,7 @@ describe("when both a drive and volume group are used for installation", () => { describe("when neither a drive nor volume group are used for installation", () => { beforeEach(() => { - mockUseApiModel.mockReturnValue(hasNothing); + mockUseModel.mockReturnValue(hasNothing); }); it("renders a no configuration alert with a button for resetting to default", () => { diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx index b6ed1dd53e..b7e7c929ba 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -23,67 +23,62 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import type { Storage } from "~/model/proposal"; +import type { ConfigModel } from "~/model/storage/config-model"; import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; -import { StorageDevice } from "~/storage"; -import { apiModel } from "~/api/storage/types"; -const vda: StorageDevice = { +const vda: Storage.Device = { sid: 59, - type: "disk", - isDrive: true, - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", + class: "drive", name: "/dev/vda", - size: 1e12, - systems: ["Windows 11", "openSUSE Leap 15.2"], + drive: { type: "disk", info: { sdCard: false, dellBoss: false } }, + block: { + start: 1, + size: 1e12, + systems: ["Windows 11", "openSUSE Leap 15.2"], + shrinking: { supported: false }, + }, }; -const vdb: StorageDevice = { +const vdb: Storage.Device = { sid: 60, - type: "disk", - isDrive: true, - description: "", - vendor: "Seagate", - model: "Unknown", - driver: ["ahci", "mmcblk"], - bus: "IDE", + class: "drive", name: "/dev/vdb", - size: 1e6, - systems: [], + drive: { type: "disk", info: { sdCard: false, dellBoss: false } }, + block: { + start: 1, + size: 1e6, + systems: [], + shrinking: { supported: false }, + }, }; -const vdaDrive: apiModel.Drive = { +const vdaDrive: ConfigModel.Drive = { name: "/dev/vda", spacePolicy: "delete", partitions: [], }; -const vdbDrive: apiModel.Drive = { +const vdbDrive: ConfigModel.Drive = { name: "/dev/vdb", spacePolicy: "delete", partitions: [], }; const mockAddDrive = jest.fn(); +const mockAddReusedMdRaid = jest.fn(); const mockUseModel = jest.fn(); -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), useAvailableDevices: () => [vda, vdb], })); -jest.mock("~/hooks/storage/model", () => ({ - ...jest.requireActual("~/hooks/storage/model"), - useModel: () => mockUseModel(), -})); - -jest.mock("~/hooks/storage/drive", () => ({ - ...jest.requireActual("~/hooks/storage/drive"), - __esModule: true, +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => mockUseModel(), useAddDrive: () => mockAddDrive, + useAddMdRaid: () => mockAddReusedMdRaid, })); describe("ConfigureDeviceMenu", () => { diff --git a/web/src/components/storage/ConnectedDevicesMenu.test.tsx b/web/src/components/storage/ConnectedDevicesMenu.test.tsx index 6d2cdafb3a..1ef4e52723 100644 --- a/web/src/components/storage/ConnectedDevicesMenu.test.tsx +++ b/web/src/components/storage/ConnectedDevicesMenu.test.tsx @@ -39,9 +39,9 @@ jest.mock("~/queries/storage/dasd", () => ({ })); const mockReactivateSystem = jest.fn(); -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), - useReactivateSystem: () => mockReactivateSystem(), +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + activateStorageAction: () => mockReactivateSystem(), })); beforeEach(() => { diff --git a/web/src/components/storage/DeviceEditorContent.test.tsx b/web/src/components/storage/DeviceEditorContent.test.tsx new file mode 100644 index 0000000000..74a60ed9b8 --- /dev/null +++ b/web/src/components/storage/DeviceEditorContent.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright (c) [2025] 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 { installerRender } from "~/test-utils"; +import DeviceEditorContent from "~/components/storage/DeviceEditorContent"; +import type { ConfigModel } from "~/model/storage/config-model"; + +jest.mock("~/components/storage/UnusedMenu", () => () =>
); + +jest.mock("~/components/storage/FilesystemMenu", () => () =>
); + +jest.mock("~/components/storage/PartitionsSection", () => () => ( +
+)); + +jest.mock("~/components/storage/SpacePolicyMenu", () => () => ( +
+)); + +const mockConfigModel = jest.fn(); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + useConfigModel: () => mockConfigModel(), +})); + +const driveWithPartitions: ConfigModel.Drive = { + name: "sda", + partitions: [{ mountPath: "/" }], +}; + +const driveWithFilesystem: ConfigModel.Drive = { + name: "sdb", + filesystem: { default: true }, + mountPath: "/boot", +}; + +const driveUnused: ConfigModel.Drive = { + name: "sdc", + partitions: [], +}; + +const baseConfig: ConfigModel.Config = { + drives: [driveWithPartitions, driveWithFilesystem, driveUnused], + mdRaids: [], +}; + +describe("DeviceEditorContent", () => { + it("renders UnusedMenu when device is not used", () => { + mockConfigModel.mockReturnValue(baseConfig); + installerRender(); + expect(screen.getByTestId("unused-menu")).toBeInTheDocument(); + }); + + it("renders FilesystemMenu when device is used and has a filesystem", () => { + mockConfigModel.mockReturnValue(baseConfig); + installerRender(); + expect(screen.getByTestId("filesystem-menu")).toBeInTheDocument(); + expect(screen.queryByTestId("partitions-section")).not.toBeInTheDocument(); + expect(screen.queryByTestId("space-policy-menu")).not.toBeInTheDocument(); + }); + + it("renders PartitionsSection and SpacePolicyMenu when device is used and has partitions", () => { + mockConfigModel.mockReturnValue(baseConfig); + installerRender(); + expect(screen.getByTestId("partitions-section")).toBeInTheDocument(); + expect(screen.getByTestId("space-policy-menu")).toBeInTheDocument(); + expect(screen.queryByTestId("filesystem-menu")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx index 3189470219..2e83d9b329 100644 --- a/web/src/components/storage/DeviceSelectorModal.test.tsx +++ b/web/src/components/storage/DeviceSelectorModal.test.tsx @@ -23,51 +23,60 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { getColumnValues, plainRender } from "~/test-utils"; -import { StorageDevice } from "~/storage"; +import type { Storage } from "~/model/system"; import DeviceSelectorModal from "./DeviceSelectorModal"; -const sda: StorageDevice = { +const sda: Storage.Device = { sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, + class: "drive", name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], description: "SDA drive", + drive: { + model: "Micron 1100 SATA", + vendor: "Micron", + bus: "IDE", + busId: "", + transport: "usb", + driver: ["ahci", "mmcblk"], + info: { + dellBoss: false, + sdCard: true, + }, + }, + block: { + start: 1, + size: 1024, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, }; -const sdb: StorageDevice = { +const sdb: Storage.Device = { sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, + class: "drive", name: "/dev/sdb", - size: 2048, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], description: "SDB drive", + block: { + start: 1, + size: 2048, + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, + drive: { + model: "Samsung Evo 8 Pro", + vendor: "Samsung", + bus: "IDE", + busId: "", + transport: "", + info: { + dellBoss: false, + sdCard: false, + }, + }, }; const onCancelMock = jest.fn(); diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index ffa5169151..9639ad8fc7 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -88,7 +88,7 @@ const DeviceSelector = ({ { name: _("Size"), value: size, - sortingKey: "size", + sortingKey: (d: Storage.Device) => d.block.size, pfTdProps: { style: { width: "10ch" } }, }, { name: _("Description"), value: description }, diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index c08e041067..e04b7ea246 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -21,279 +21,101 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import DriveEditor from "~/components/storage/DriveEditor"; -import { StorageDevice, model } from "~/storage"; -import { Volume } from "~/api/storage/types"; +import type { Storage as System } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; -const mockDeleteDrive = jest.fn(); -const mockSwitchToDrive = jest.fn(); -const mockUseModel = jest.fn(); +jest.mock("~/components/storage/DeviceEditorContent", () => () => ( +
+)); -const volume1: Volume = { - mountPath: "/", - mountOptions: [], - target: "default", - fsType: "Btrfs", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - }, -}; +const mockDriveModel = jest.fn(); +const mockConfigModel = jest.fn(); +const mockDeleteDrive = jest.fn(); +const mockAddVolumeGroupFromPartitionable = jest.fn(); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useDrive: () => mockDriveModel(), + useConfigModel: () => mockConfigModel(), + useAddDriveFromMdRaid: jest.fn(), + useAddMdRaidFromDrive: jest.fn(), + useDeleteDrive: () => mockDeleteDrive, + useAddVolumeGroupFromPartitionable: () => mockAddVolumeGroupFromPartitionable, +})); -const volume2: Volume = { - mountPath: "swap", - mountOptions: [], - target: "default", - fsType: "Swap", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Swap"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - }, -}; +const mockSystemDevice = jest.fn(); +const mockAvailableDevices = jest.fn(); -const volume3: Volume = { - mountPath: "/home", - mountOptions: [], - target: "default", - fsType: "XFS", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - }, -}; - -const sda: StorageDevice = { - sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - description: "", -}; +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useDevice: () => mockSystemDevice(), + useAvailableDevices: () => mockAvailableDevices(), +})); -const sdb: StorageDevice = { - sid: 60, - isDrive: true, - type: "disk", - name: "/dev/sdb", - size: 1024, - description: "", +const drive1Model: ConfigModel.Drive = { + name: "sda", + spacePolicy: "custom", + partitions: [], }; -const drive1Partitions: model.Partition[] = [ - { - mountPath: "/", - size: { - min: 1_000_000_000, - default: true, - }, - filesystem: { default: true, type: "btrfs" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, - { - mountPath: "swap", - size: { - min: 2_000_000_000, - default: false, // false: user provided, true: calculated - }, - filesystem: { default: false, type: "swap" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, -]; - -const drive1 = { - name: "/dev/sda", - spacePolicy: "delete", - partitions: drive1Partitions, - list: "drives", - listIndex: 1, - isUsed: true, - isAddingPartitions: true, - isTargetDevice: false, - isBoot: true, - isExplicitBoot: true, - getVolumeGroups: () => [], - getPartition: jest.fn(), - getMountPaths: () => drive1Partitions.map((p) => p.mountPath), - getConfiguredExistingPartitions: jest.fn(), +const drive2Model: ConfigModel.Drive = { + name: "sdb", + spacePolicy: "keep", }; -const drive2Partitions: model.Partition[] = [ - { - mountPath: "/home", - size: { - min: 1_000_000_000, - default: true, - }, - filesystem: { default: true, type: "xfs" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, +const sda: System.Device = { + sid: 1, + name: "sda", + class: "drive", + drive: { + type: "disk", }, -]; - -const drive2 = { - name: "/dev/sdb", - spacePolicy: "delete", - partitions: drive2Partitions, - list: "drives", - listIndex: 2, - isExplicitBoot: false, - isUsed: true, - isAddingPartitions: true, - isTargetDevice: false, - isBoot: true, - getVolumeGroups: () => [], - getPartition: jest.fn(), - getMountPaths: () => drive2Partitions.map((p) => p.mountPath), - getConfiguredExistingPartitions: jest.fn(), }; -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useVolume: (mountPath: string): Volume => - [volume1, volume2, volume3].find((v) => v.mountPath === mountPath), -})); - -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), - useAvailableDevices: () => [sda, sdb], - useCandidateDevices: () => [sda], -})); - -jest.mock("~/hooks/storage/drive", () => ({ - ...jest.requireActual("~/hooks/storage/drive"), - __esModule: true, - useDeleteDrive: () => mockDeleteDrive, - useSwitchToDrive: () => mockSwitchToDrive, -})); - -jest.mock("~/hooks/storage/model", () => ({ - ...jest.requireActual("~/hooks/storage/model"), - useModel: () => mockUseModel(), -})); - -describe("RemoveDriveOption", () => { - describe("if there are additional drives", () => { - beforeEach(() => { - mockUseModel.mockReturnValue({ drives: [drive1, drive2], mdRaids: [] }); +describe("DriveEditor", () => { + beforeEach(() => { + mockAvailableDevices.mockReturnValue([sda]); + mockConfigModel.mockReturnValue({ + drives: [drive1Model, drive2Model], + mdRaids: [], }); + }); - it("allows users to delete regular drives", async () => { - // @ts-expect-error: drives are not typed on purpose because - // isReusingPartitions should be a calculated data. Mocking needs a lot of - // improvements. - const { user } = installerRender(); + it("should render null if device is not found", () => { + mockDriveModel.mockReturnValue(drive1Model); + mockSystemDevice.mockReturnValue(undefined); - const changeButton = screen.getByRole("button", { name: /Use disk sdb/ }); - await user.click(changeButton); - const drivesMenu = screen.getByRole("menu", { name: "Device /dev/sdb menu" }); - const deleteDriveButton = within(drivesMenu).getByRole("menuitem", { - name: /Do not use/, - }); - await user.click(deleteDriveButton); - expect(mockDeleteDrive).toHaveBeenCalled(); - }); + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); - it("does not allow users to delete drives explicitly used to boot", async () => { - // @ts-expect-error: drives are not typed on purpose because - // isReusingPartitions should be a calculated data. Mocking needs a lot of - // improvements. - const { user } = installerRender(); + it("should render the editor for the given drive", () => { + mockDriveModel.mockReturnValue(drive1Model); + mockSystemDevice.mockReturnValue(sda); - const changeButton = screen.getByRole("button", { name: /Use disk sda/ }); - await user.click(changeButton); - const drivesMenu = screen.getByRole("menu", { name: "Device /dev/sda menu" }); - const deleteDriveButton = within(drivesMenu).queryByRole("menuitem", { - name: /Do not use/, - }); - expect(deleteDriveButton).toBeDisabled(); - }); + installerRender(); + + expect(screen.getByText(/sda/)).toBeInTheDocument(); + expect(screen.getByTestId("device-editor-content")).toBeInTheDocument(); }); - describe("if there are no additional drives", () => { - it("does not allow users to delete regular drives", async () => { - mockUseModel.mockReturnValue({ drives: [drive2], mdRaids: [] }); - // @ts-expect-error: drives are not typed on purpose because - // isReusingPartitions should be a calculated data. Mocking needs a lot of - // improvements. - const { user } = installerRender(); + it("should call delete drive when 'Do not use' is clicked", async () => { + mockDriveModel.mockReturnValue(drive1Model); + mockSystemDevice.mockReturnValue(sda); - const changeButton = screen.getByRole("button", { name: /Use disk sdb/ }); - await user.click(changeButton); - const drivesMenu = screen.getByRole("menu", { name: "Device /dev/sdb menu" }); - const deleteDriveButton = within(drivesMenu).queryByRole("menuitem", { - name: /Do not use/, - }); - expect(deleteDriveButton).not.toBeInTheDocument(); - }); + const { user } = installerRender(); - it("does not allow users to delete drives explicitly used to boot", async () => { - mockUseModel.mockReturnValue({ drives: [drive1], mdRaids: [] }); - // @ts-expect-error: drives are not typed on purpose because - // isReusingPartitions should be a calculated data. Mocking needs a lot of - // improvements. - const { user } = installerRender(); + // The component uses a custom toggle, we need to get the button by its content + const toggleButton = screen.getByText(/sda/).closest("button"); + expect(toggleButton).toBeInTheDocument(); + await user.click(toggleButton!); - const changeButton = screen.getByRole("button", { name: /Use disk sda/ }); - await user.click(changeButton); - const drivesMenu = screen.getByRole("menu", { name: "Device /dev/sda menu" }); - const deleteDriveButton = within(drivesMenu).queryByRole("menuitem", { - name: /Do not use/, - }); - expect(deleteDriveButton).not.toBeInTheDocument(); - }); + const deleteButton = screen.getByText("Do not use"); + await user.click(deleteButton); + + expect(mockDeleteDrive).toHaveBeenCalledWith(0); }); }); diff --git a/web/src/components/storage/EncryptionSection.test.tsx b/web/src/components/storage/EncryptionSection.test.tsx index 1d83c8aa4e..928679e340 100644 --- a/web/src/components/storage/EncryptionSection.test.tsx +++ b/web/src/components/storage/EncryptionSection.test.tsx @@ -26,10 +26,9 @@ import { installerRender } from "~/test-utils"; import EncryptionSection from "./EncryptionSection"; import { STORAGE } from "~/routes/paths"; -const mockUseEncryption = jest.fn(); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useEncryption: () => mockUseEncryption(), +const mockUseConfigModel = jest.fn(); +jest.mock("~/hooks/model/storage/config-model", () => ({ + useConfigModel: () => mockUseConfigModel(), })); jest.mock("~/components/users/PasswordCheck", () => () =>
PasswordCheck Mock
); @@ -37,7 +36,7 @@ jest.mock("~/components/users/PasswordCheck", () => () =>
PasswordCheck Moc describe("EncryptionSection", () => { describe("if encryption is enabled", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue({ + mockUseConfigModel.mockReturnValue({ encryption: { method: "luks2", password: "12345", @@ -52,7 +51,7 @@ describe("EncryptionSection", () => { describe("and uses TPM", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue({ + mockUseConfigModel.mockReturnValue({ encryption: { method: "tpmFde", password: "12345", @@ -69,7 +68,7 @@ describe("EncryptionSection", () => { describe("if encryption is disabled", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue({}); + mockUseConfigModel.mockReturnValue({}); }); it("renders encryption as disabled", () => { diff --git a/web/src/components/storage/EncryptionSettingsPage.test.tsx b/web/src/components/storage/EncryptionSettingsPage.test.tsx index 9722a44fd8..5f41a3b942 100644 --- a/web/src/components/storage/EncryptionSettingsPage.test.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.test.tsx @@ -24,58 +24,62 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import EncryptionSettingsPage from "./EncryptionSettingsPage"; -import { EncryptionHook } from "~/queries/storage/config-model"; +import type { ConfigModel } from "~/model/storage/config-model"; jest.mock("~/components/users/PasswordCheck", () => () =>
PasswordCheck Mock
); -const mockLuks2Encryption: EncryptionHook = { +const mockLuks2Config: ConfigModel.Config = { encryption: { method: "luks2", password: "12345", }, - enable: jest.fn(), - disable: jest.fn(), }; -const mockTpmEncryption: EncryptionHook = { +const mockTpmConfig: ConfigModel.Config = { encryption: { method: "tpmFde", password: "12345", }, - enable: jest.fn(), - disable: jest.fn(), }; -const mockNoEncryption: EncryptionHook = { - encryption: undefined, - enable: jest.fn(), - disable: jest.fn(), -}; +const mockNoEncryptionConfig: ConfigModel.Config = {}; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
registration alert
)); +jest.mock("~/hooks/model/system", () => ({ + useSystem: () => ({ + l10n: { + keymap: "us", + timezone: "Europe/Berlin", + locale: "en_US", + }, + }), +})); + const mockUseEncryptionMethods = jest.fn(); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), +jest.mock("~/hooks/model/system/storage", () => ({ useEncryptionMethods: () => mockUseEncryptionMethods(), })); -const mockUseEncryption = jest.fn(); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useEncryption: () => mockUseEncryption(), +const mockUseConfigModel = jest.fn(); +const mockSetEncryption = jest.fn(); +jest.mock("~/hooks/model/storage/config-model", () => ({ + useConfigModel: () => mockUseConfigModel(), + useSetEncryption: () => mockSetEncryption, })); describe("EncryptionSettingsPage", () => { beforeEach(() => { mockUseEncryptionMethods.mockReturnValue(["luks2", "tpmFde"]); + mockSetEncryption.mockClear(); + mockUseConfigModel.mockClear(); }); describe("when encryption is not enabled", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue(mockNoEncryption); + mockUseConfigModel.mockReturnValue(mockNoEncryptionConfig); }); it("allows enabling the encryption", async () => { @@ -89,13 +93,13 @@ describe("EncryptionSettingsPage", () => { await user.type(passwordConfirmationInput, "12345"); const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(acceptButton); - expect(mockNoEncryption.enable).toHaveBeenCalledWith("luks2", "12345"); + expect(mockSetEncryption).toHaveBeenCalledWith({ method: "luks2", password: "12345" }); }); }); describe("when encryption is enabled", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue(mockLuks2Encryption); + mockUseConfigModel.mockReturnValue(mockLuks2Config); }); it("allows disabling the encryption", async () => { @@ -106,13 +110,13 @@ describe("EncryptionSettingsPage", () => { const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(acceptButton); - expect(mockLuks2Encryption.disable).toHaveBeenCalled(); + expect(mockSetEncryption).toHaveBeenCalledWith(null); }); }); describe("when using TPM", () => { beforeEach(() => { - mockUseEncryption.mockReturnValue(mockTpmEncryption); + mockUseConfigModel.mockReturnValue(mockTpmConfig); }); it("allows disabling TPM", async () => { @@ -123,7 +127,7 @@ describe("EncryptionSettingsPage", () => { await user.click(tpmCheckbox); expect(tpmCheckbox).not.toBeChecked(); await user.click(acceptButton); - expect(mockTpmEncryption.enable).toHaveBeenCalledWith("luks2", "12345"); + expect(mockSetEncryption).toHaveBeenCalledWith({ method: "luks2", password: "12345" }); }); }); diff --git a/web/src/components/storage/FilesystemMenu.test.tsx b/web/src/components/storage/FilesystemMenu.test.tsx new file mode 100644 index 0000000000..908e2cfdaa --- /dev/null +++ b/web/src/components/storage/FilesystemMenu.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright (c) [2025] 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 { generatePath } from "react-router"; +import { installerRender, mockNavigateFn } from "~/test-utils"; +import FilesystemMenu from "./FilesystemMenu"; +import { STORAGE as PATHS } from "~/routes/paths"; +import type { ConfigModel } from "~/model/storage/config-model"; + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useNavigate: () => mockNavigateFn, +})); + +const mockPartitionable = jest.fn(); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + usePartitionable: () => mockPartitionable(), +})); + +describe("FilesystemMenu", () => { + it("should render the toggle button and open the menu on click", async () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + mountPath: "/home", + filesystem: { type: "btrfs", default: false, snapshots: true }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + const { user } = installerRender(); + + // Test that the toggle button renders with the correct description + const toggleButton = screen.getByRole("button", { + name: 'The device will be formatted as Btrfs with snapshots and mounted at "/home"', + }); + expect(toggleButton).toBeInTheDocument(); + + // Open the menu + await user.click(toggleButton); + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByText("Edit")).toBeInTheDocument(); + }); + + it("should navigate to edit filesystem path when 'Edit' is clicked", async () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + mountPath: "/home", + filesystem: { type: "btrfs", default: false, snapshots: true }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + const { user } = installerRender(); + const toggleButton = screen.getByRole("button", { + name: 'The device will be formatted as Btrfs with snapshots and mounted at "/home"', + }); + await user.click(toggleButton); + + const editItem = screen.getByText("Edit"); + await user.click(editItem); + + const expectedPath = generatePath(PATHS.formatDevice, { collection: "drives", index: 0 }); + expect(mockNavigateFn).toHaveBeenCalledWith(expectedPath); + }); + + describe("deviceDescription function logic", () => { + it("should return 'The device will be mounted' when no mount path and reuse = true", () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + filesystem: { reuse: true, default: false }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + installerRender(); + expect( + screen.getByRole("button", { name: "The device will be mounted" }), + ).toBeInTheDocument(); + }); + + it("should return 'The device will be formatted' when no mount path and reuse = false", () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + filesystem: { reuse: false, default: false }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + installerRender(); + expect( + screen.getByRole("button", { name: "The device will be formatted" }), + ).toBeInTheDocument(); + }); + + it("should return 'The current file system will be mounted at \"/home\"' when mount path and reuse = true", () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + mountPath: "/home", + filesystem: { reuse: true, default: false }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + installerRender(); + expect( + screen.getByRole("button", { name: 'The current file system will be mounted at "/home"' }), + ).toBeInTheDocument(); + }); + + it("should return 'The device will be formatted as XFS and mounted at \"/var\"' when mount path and reuse = false", () => { + const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + mountPath: "/var", + filesystem: { reuse: false, default: false, type: "xfs" }, + }; + mockPartitionable.mockReturnValue(deviceModel); + + installerRender(); + expect( + screen.getByRole("button", { + name: 'The device will be formatted as XFS and mounted at "/var"', + }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/storage/FormattableDevicePage.test.tsx b/web/src/components/storage/FormattableDevicePage.test.tsx index a3177b70ef..c0fa6f24d8 100644 --- a/web/src/components/storage/FormattableDevicePage.test.tsx +++ b/web/src/components/storage/FormattableDevicePage.test.tsx @@ -24,41 +24,31 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import FormattableDevicePage from "~/components/storage/FormattableDevicePage"; -import { StorageDevice, model } from "~/storage"; -import { Volume } from "~/api/storage/types"; +import type { Storage } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; import { gib } from "./utils"; -const sda: StorageDevice = { +const sda: Storage.Device = { sid: 59, - isDrive: true, - type: "disk", + class: "drive", name: "/dev/sda", - size: gib(10), description: "", + block: { + start: 1, + size: gib(10), + shrinking: { supported: false }, + }, }; -const sdaModel: model.Drive = { +const sdaModel: ConfigModel.Drive = { name: "/dev/sda", spacePolicy: "keep", partitions: [], - list: "drives", - listIndex: 0, - isExplicitBoot: false, - isUsed: true, - isAddingPartitions: true, - isReusingPartitions: true, - isTargetDevice: false, - isBoot: true, - getMountPaths: jest.fn(), - getVolumeGroups: jest.fn(), - getPartition: jest.fn(), - getConfiguredExistingPartitions: jest.fn(), }; -const homeVolume: Volume = { +const homeVolume: Storage.Volume = { mountPath: "/home", mountOptions: [], - target: "default", fsType: "btrfs", minSize: gib(1), maxSize: gib(5), @@ -76,40 +66,38 @@ const homeVolume: Volume = { }, }; -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssues: () => [], -})); - -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => [sda], -})); - -const mockModel = jest.fn(); -jest.mock("~/hooks/storage/model", () => ({ - ...jest.requireActual("~/hooks/storage/model"), - useModel: () => mockModel(), +const mockUseDevice = jest.fn(); +const mockUsePartitionable = jest.fn(); +const mockUseConfigModel = jest.fn(); +const mockUseMissingMountPaths = jest.fn(); +const mockUseVolumeTemplate = jest.fn(); +const mockSetFilesystem = jest.fn(); + +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useDevice: (name: string) => mockUseDevice(name), + useVolumeTemplate: (mountPath: string) => mockUseVolumeTemplate(mountPath), })); -jest.mock("~/hooks/storage/product", () => ({ - ...jest.requireActual("~/hooks/storage/product"), - useMissingMountPaths: () => ["/home", "swap"], - useVolume: () => homeVolume, +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + usePartitionable: (collection: string, index: number) => mockUsePartitionable(collection, index), + useConfigModel: () => mockUseConfigModel(), + useMissingMountPaths: () => mockUseMissingMountPaths(), + useSetFilesystem: () => mockSetFilesystem, })); -const mockAddFilesystem = jest.fn(); -jest.mock("~/hooks/storage/filesystem", () => ({ - ...jest.requireActual("~/hooks/storage/filesystem"), - useAddFilesystem: () => mockAddFilesystem, -})); +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
registration alert
+)); beforeEach(() => { - mockParams({ list: "drives", listIndex: "0" }); - mockModel.mockReturnValue({ - drives: [sdaModel], - getMountPaths: () => [], - }); + mockParams({ collection: "drives", index: "0" }); + mockUsePartitionable.mockReturnValue(sdaModel); + mockUseDevice.mockReturnValue(sda); + mockUseConfigModel.mockReturnValue({ drives: [sdaModel] }); + mockUseMissingMountPaths.mockReturnValue(["/home", "swap"]); + mockUseVolumeTemplate.mockReturnValue(homeVolume); }); describe("FormattableDevicePage", () => { @@ -161,7 +149,7 @@ describe("FormattableDevicePage", () => { }); describe("if the device has already a filesystem config", () => { - const formattedSdaModel: model.Drive = { + const formattedSdaModel: ConfigModel.Drive = { ...sdaModel, mountPath: "/home", filesystem: { @@ -171,11 +159,15 @@ describe("FormattableDevicePage", () => { }, }; + const formattedSda: Storage.Device = { + ...sda, + filesystem: { sid: 100, type: "xfs" }, + }; + beforeEach(() => { - mockModel.mockReturnValue({ - drives: [formattedSdaModel], - getMountPaths: () => [], - }); + mockUsePartitionable.mockReturnValue(formattedSdaModel); + mockUseDevice.mockReturnValue(formattedSda); + mockUseConfigModel.mockReturnValue({ drives: [formattedSdaModel] }); }); it("initializes the form with the current values", async () => { @@ -206,7 +198,7 @@ describe("FormattableDevicePage", () => { await user.type(labelInput, "TEST"); const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(acceptButton); - expect(mockAddFilesystem).toHaveBeenCalledWith("drives", 0, { + expect(mockSetFilesystem).toHaveBeenCalledWith("drives", 0, { mountPath: "/home", filesystem: { type: "xfs", diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index 363c69c008..7382040cfd 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -658,7 +658,7 @@ export default function LogicalVolumePage() { const changeSizeMode = (mode: SizeMode, size: SizeRange) => { setSizeOption(mode); setMinSize(size.min); - if (mode === "custom" && initialValue.sizeOption === "auto" && size.min !== size.max) { + if (mode === "custom" && initialValue?.sizeOption === "auto" && size.min !== size.max) { // Automatically stop using a range of sizes when a range is used by default. setMaxSize(""); } else { diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 358aebc5dc..b00cef3df9 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -23,60 +23,72 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; -import { model, StorageDevice } from "~/storage"; +import type { ConfigModel } from "~/model/storage/config-model"; +import type { Storage } from "~/model/system"; import { gib } from "./utils"; import LvmPage from "./LvmPage"; -const sda1: StorageDevice = { +const sda1: Storage.Device = { sid: 69, + class: "partition", name: "/dev/sda1", description: "Swap partition", - isDrive: false, - type: "partition", - size: gib(2), - shrinking: { unsupported: ["Resizing is not supported"] }, - start: 1, + block: { + start: 1, + size: gib(2), + shrinking: { supported: false }, + }, }; -const sda: StorageDevice = { +const sda: Storage.Device = { sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, + class: "drive", name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - partitionTable: { - type: "gpt", - partitions: [sda1], - unpartitionedSize: 0, - unusedSlots: [{ start: 3, size: gib(2) }], + description: "SDA drive", + drive: { + type: "disk", + model: "Micron 1100 SATA", + vendor: "Micron", + bus: "IDE", + busId: "", + transport: "usb", + driver: ["ahci", "mmcblk"], + info: { + dellBoss: false, + sdCard: true, + }, }, - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - description: "", + block: { + start: 1, + size: gib(20), + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, + }, + partitions: [sda1], }; -const sdb: StorageDevice = { +const sdb: Storage.Device = { sid: 60, - isDrive: true, - type: "disk", + class: "drive", name: "/dev/sdb", - size: 1024, - systems: [], - description: "", + block: { + start: 1, + size: gib(10), + shrinking: { supported: false }, + systems: [], + }, + drive: { + type: "disk", + info: { + dellBoss: false, + sdCard: false, + }, + }, }; -const mockSdaDrive: model.Drive = { +const mockSdaDrive: ConfigModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ @@ -84,13 +96,9 @@ const mockSdaDrive: model.Drive = { mountPath: "swap", size: { min: gib(2), - default: false, // false: user provided, true: calculated + default: false, }, - filesystem: { default: false, type: "swap" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, + filesystem: { default: true, type: "swap" }, }, { mountPath: "/home", @@ -98,93 +106,68 @@ const mockSdaDrive: model.Drive = { min: gib(16), default: true, }, - filesystem: { default: false, type: "xfs" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, + filesystem: { default: true, type: "xfs" }, }, ], - list: "drives", - listIndex: 1, - isExplicitBoot: false, - isUsed: true, - isAddingPartitions: true, - isReusingPartitions: true, - isTargetDevice: true, - isBoot: false, - getMountPaths: () => ["/home", "swap"], - getVolumeGroups: () => [], - getPartition: jest.fn(), - getConfiguredExistingPartitions: jest.fn(), }; -const mockRootVolumeGroup: model.VolumeGroup = { +const mockRootVolumeGroup: ConfigModel.VolumeGroup = { vgName: "fakeRootVg", - list: "volumeGroups", - listIndex: 1, + targetDevices: ["/dev/sda"], logicalVolumes: [], - getTargetDevices: () => [mockSdaDrive], - getMountPaths: () => [], }; -const mockHomeVolumeGroup: model.VolumeGroup = { +const mockHomeVolumeGroup: ConfigModel.VolumeGroup = { vgName: "fakeHomeVg", - list: "volumeGroups", - listIndex: 2, + targetDevices: ["/dev/sda"], logicalVolumes: [], - getTargetDevices: () => [mockSdaDrive], - getMountPaths: () => [], }; const mockAddVolumeGroup = jest.fn(); const mockEditVolumeGroup = jest.fn(); +const mockUseConfigModel = jest.fn(); +const mockUseVolumeGroup = jest.fn(); +const mockUseAvailableDevices = jest.fn(); -let mockUseModel = { - drives: [mockSdaDrive], - mdRaids: [], - volumeGroups: [], -}; - -const mockUseAllDevices = [sda, sdb]; - -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssuesChanges: jest.fn(), - useIssues: () => [], -})); - -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => mockUseAllDevices, -})); - -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), - useAvailableDevices: () => mockUseAllDevices, -})); - -jest.mock("~/hooks/storage/model", () => ({ - ...jest.requireActual("~/hooks/storage/model"), - __esModule: true, - useModel: () => mockUseModel, +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useAvailableDevices: () => mockUseAvailableDevices(), })); -jest.mock("~/hooks/storage/volume-group", () => ({ - ...jest.requireActual("~/hooks/storage/volume-group"), +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), __esModule: true, + useConfigModel: () => mockUseConfigModel(), + useVolumeGroup: (id?: string) => mockUseVolumeGroup(id), useAddVolumeGroup: () => mockAddVolumeGroup, useEditVolumeGroup: () => mockEditVolumeGroup, })); +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
registration alert
+)); + describe("LvmPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockParams({}); + mockUseAvailableDevices.mockReturnValue([sda, sdb]); + mockUseVolumeGroup.mockReturnValue(undefined); + }); + describe("when creating a new volume group", () => { it("allows configuring a new LVM volume group (without moving mount points)", async () => { + mockUseConfigModel.mockReturnValue({ + drives: [mockSdaDrive], + mdRaids: [], + volumeGroups: [], + }); + const { user } = installerRender(); const name = screen.getByRole("textbox", { name: "Name" }); const disks = screen.getByRole("group", { name: "Disks" }); - const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (1 KiB)" }); - const sdbCheckbox = within(disks).getByRole("checkbox", { name: "sdb (1 KiB)" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (20 GiB)" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "sdb (10 GiB)" }); const moveMountPointsCheckbox = screen.getByRole("checkbox", { name: /Move the mount points currently configured at the selected disks to logical volumes/, }); @@ -209,9 +192,15 @@ describe("LvmPage", () => { }); it("allows configuring a new LVM volume group (moving mount points)", async () => { + mockUseConfigModel.mockReturnValue({ + drives: [mockSdaDrive], + mdRaids: [], + volumeGroups: [], + }); + const { user } = installerRender(); const disks = screen.getByRole("group", { name: "Disks" }); - const sdbCheckbox = within(disks).getByRole("checkbox", { name: "sdb (1 KiB)" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "sdb (10 GiB)" }); const moveMountPointsCheckbox = screen.getByRole("checkbox", { name: /Move the mount points currently configured at the selected disks to logical volumes/, }); @@ -227,10 +216,16 @@ describe("LvmPage", () => { }); it("performs basic validations", async () => { + mockUseConfigModel.mockReturnValue({ + drives: [mockSdaDrive], + mdRaids: [], + volumeGroups: [], + }); + const { user } = installerRender(); const name = screen.getByRole("textbox", { name: "Name" }); const disks = screen.getByRole("group", { name: "Disks" }); - const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (1 KiB)" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (20 GiB)" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); // Unselect sda @@ -262,11 +257,11 @@ describe("LvmPage", () => { describe("when there are LVM volume groups", () => { beforeEach(() => { - mockUseModel = { + mockUseConfigModel.mockReturnValue({ drives: [mockSdaDrive], mdRaids: [], volumeGroups: [mockRootVolumeGroup], - }; + }); }); it("does not pre-fill the name input", () => { @@ -278,11 +273,11 @@ describe("LvmPage", () => { describe("when there are no LVM volume groups yet", () => { beforeEach(() => { - mockUseModel = { + mockUseConfigModel.mockReturnValue({ drives: [mockSdaDrive], mdRaids: [], volumeGroups: [], - }; + }); }); it("pre-fills the name input with 'system'", () => { @@ -296,18 +291,19 @@ describe("LvmPage", () => { describe("when editing", () => { beforeEach(() => { mockParams({ id: "fakeRootVg" }); - mockUseModel = { + mockUseConfigModel.mockReturnValue({ drives: [mockSdaDrive], mdRaids: [], volumeGroups: [mockRootVolumeGroup, mockHomeVolumeGroup], - }; + }); + mockUseVolumeGroup.mockReturnValue(mockRootVolumeGroup); }); it("performs basic validations", async () => { const { user } = installerRender(); const name = screen.getByRole("textbox", { name: "Name" }); const disks = screen.getByRole("group", { name: "Disks" }); - const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (1 KiB)" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "sda (20 GiB)" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); // Let's clean the default given name @@ -329,7 +325,7 @@ describe("LvmPage", () => { it("pre-fills form with the current volume group configuration", async () => { installerRender(); const name = screen.getByRole("textbox", { name: "Name" }); - const sdaCheckbox = screen.getByRole("checkbox", { name: "sda (1 KiB)" }); + const sdaCheckbox = screen.getByRole("checkbox", { name: "sda (20 GiB)" }); expect(name).toHaveValue("fakeRootVg"); expect(sdaCheckbox).toBeChecked(); }); diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx index fc1a5b0f18..a182c7ab8b 100644 --- a/web/src/components/storage/PartitionPage.test.tsx +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -24,162 +24,143 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import PartitionPage from "./PartitionPage"; -import { StorageDevice, model } from "~/storage"; -import { apiModel, Volume } from "~/api/storage/types"; +import type { ConfigModel } from "~/model/storage/config-model"; +import type { Storage } from "~/model/system"; import { gib } from "./utils"; -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssuesChanges: jest.fn(), - useIssues: () => [], -})); - jest.mock("./ProposalResultSection", () => () =>
result section
); jest.mock("./ProposalTransactionalInfo", () => () =>
transactional info
); +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
registration alert
+)); -const mockGetPartition = jest.fn(); - -const sda1: StorageDevice = { +const sda1: Storage.Device = { sid: 69, + class: "partition", name: "/dev/sda1", description: "Swap partition", - isDrive: false, - type: "partition", - size: gib(2), - shrinking: { unsupported: ["Resizing is not supported"] }, - start: 1, + block: { + start: 1, + size: gib(2), + shrinking: { supported: false }, + }, }; -const sda: StorageDevice = { +const sda: Storage.Device = { sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, + class: "drive", name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - partitionTable: { - type: "gpt", - partitions: [sda1], - unpartitionedSize: 0, - unusedSlots: [{ start: 3, size: gib(2) }], + description: "SDA drive", + drive: { + type: "disk", + model: "Micron 1100 SATA", + vendor: "Micron", + bus: "IDE", + busId: "", + transport: "usb", + driver: ["ahci", "mmcblk"], + info: { + dellBoss: false, + sdCard: true, + }, + }, + block: { + start: 1, + size: gib(20), + active: true, + encrypted: false, + systems: [], + shrinking: { supported: false }, }, - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - description: "", + partitions: [sda1], }; -const mockPartition: model.Partition = { - isNew: false, - isUsed: true, - isReused: false, - isUsedBySpacePolicy: false, +const swap: ConfigModel.Partition = { + mountPath: "swap", + size: { + min: gib(2), + default: false, + }, + filesystem: { default: false, type: "swap" }, +}; + +const home: ConfigModel.Partition = { + mountPath: "/home", + size: { + default: false, + min: gib(5), + max: gib(5), + }, + filesystem: { + default: false, + type: "xfs", + label: "HOME", + }, }; -const mockDrive: model.Drive = { +const drive: ConfigModel.Drive = { name: "/dev/sda", spacePolicy: "delete", - partitions: [ - { - mountPath: "swap", - size: { - min: gib(2), - default: false, // false: user provided, true: calculated - }, - filesystem: { default: false, type: "swap" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, - { - mountPath: "/home", - size: { - min: gib(16), - default: true, - }, - filesystem: { default: false, type: "xfs" }, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, - ], - list: "drives", - listIndex: 1, - isExplicitBoot: false, - isUsed: true, - isAddingPartitions: true, - isReusingPartitions: true, - isTargetDevice: false, - isBoot: true, - getMountPaths: jest.fn(), - getVolumeGroups: jest.fn(), - getPartition: mockGetPartition, - getConfiguredExistingPartitions: () => [mockPartition], + partitions: [swap], }; -const mockSolvedConfigModel: apiModel.Config = { - drives: [mockDrive], +const driveWithHome: ConfigModel.Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [swap, home], }; -const mockHomeVolume: Volume = { +const homeVolume: Storage.Volume = { mountPath: "/home", - mountOptions: [], - target: "default", fsType: "btrfs", minSize: 1024, maxSize: 1024, - autoSize: false, snapshots: false, - transactional: false, + autoSize: false, outline: { required: false, fsTypes: ["btrfs"], - supportAutoSize: false, snapshotsConfigurable: false, snapshotsAffectSizes: false, + supportAutoSize: false, sizeRelevantVolumes: [], - adjustByRam: false, }, }; -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => [sda], - useVolume: () => mockHomeVolume, -})); - -jest.mock("~/hooks/storage/model", () => ({ - ...jest.requireActual("~/hooks/storage/model"), - useModel: () => ({ - drives: [mockDrive], - getMountPaths: () => [], - }), -})); +const mockUseDevice = jest.fn(); +const mockUsePartitionable = jest.fn(); +const mockUseConfigModel = jest.fn(); +const mockUseSolvedConfigModel = jest.fn(); +const mockUseMissingMountPaths = jest.fn(); +const mockUseVolumeTemplate = jest.fn(); +const mockAddPartition = jest.fn(); +const mockEditPartition = jest.fn(); -jest.mock("~/hooks/storage/product", () => ({ - ...jest.requireActual("~/hooks/storage/product"), - useMissingMountPaths: () => ["/home", "swap"], +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useDevice: (name: string) => mockUseDevice(name), + useVolumeTemplate: (mountPath: string) => mockUseVolumeTemplate(mountPath), })); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useConfigModel: () => ({ drives: [mockDrive] }), - useSolvedConfigModel: () => mockSolvedConfigModel, +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + usePartitionable: (collection: string, index: number) => mockUsePartitionable(collection, index), + useConfigModel: () => mockUseConfigModel(), + useSolvedConfigModel: (model?: ConfigModel.Config) => mockUseSolvedConfigModel(model), + useMissingMountPaths: () => mockUseMissingMountPaths(), + useAddPartition: () => mockAddPartition, + useEditPartition: () => mockEditPartition, })); beforeEach(() => { - mockParams({ list: "drives", listIndex: "0" }); + jest.clearAllMocks(); + mockParams({ collection: "drives", index: "0" }); + mockUsePartitionable.mockReturnValue(drive); + mockUseDevice.mockReturnValue(sda); + mockUseConfigModel.mockReturnValue({ drives: [drive] }); + mockUseSolvedConfigModel.mockReturnValue({ drives: [driveWithHome] }); + mockUseMissingMountPaths.mockReturnValue(["/home", "swap"]); + mockUseVolumeTemplate.mockReturnValue(homeVolume); }); describe("PartitionPage", () => { @@ -282,20 +263,9 @@ describe("PartitionPage", () => { describe("if editing a partition", () => { beforeEach(() => { - mockParams({ list: "drives", listIndex: "0", partitionId: "/home" }); - mockGetPartition.mockReturnValue({ - mountPath: "/home", - size: { - default: false, - min: gib(5), - max: gib(5), - }, - filesystem: { - default: false, - type: "xfs", - label: "HOME", - }, - }); + mockParams({ collection: "drives", index: "0", partitionId: "/home" }); + mockUsePartitionable.mockReturnValue(driveWithHome); + mockUseConfigModel.mockReturnValue({ drives: [driveWithHome] }); }); it("initializes the form with the partition values", async () => { @@ -318,18 +288,16 @@ describe("PartitionPage", () => { describe("if the max size is unlimited", () => { beforeEach(() => { - mockParams({ list: "drives", listIndex: "0", partitionId: "/home" }); - mockGetPartition.mockReturnValue({ - mountPath: "/home", - size: { - default: false, - min: gib(5), - }, - filesystem: { - default: false, - type: "xfs", - }, - }); + const unlimitedHome = { + ...home, + size: { default: false, min: gib(5) }, + }; + const driveWithUnlimited = { + ...driveWithHome, + partitions: [swap, unlimitedHome], + }; + mockUsePartitionable.mockReturnValue(driveWithUnlimited); + mockUseConfigModel.mockReturnValue({ drives: [driveWithUnlimited] }); }); it("checks allow growing", async () => { @@ -341,19 +309,16 @@ describe("PartitionPage", () => { describe("if the max size has a value", () => { beforeEach(() => { - mockParams({ list: "drives", listIndex: "0", partitionId: "/home" }); - mockGetPartition.mockReturnValue({ - mountPath: "/home", - size: { - default: false, - min: gib(5), - max: gib(10), - }, - filesystem: { - default: false, - type: "xfs", - }, - }); + const rangedHome = { + ...home, + size: { default: false, min: gib(5), max: gib(10) }, + }; + const driveWithRange = { + ...driveWithHome, + partitions: [swap, rangedHome], + }; + mockUsePartitionable.mockReturnValue(driveWithRange); + mockUseConfigModel.mockReturnValue({ drives: [driveWithRange] }); }); it("allows switching to a fixed size", async () => { @@ -369,19 +334,16 @@ describe("PartitionPage", () => { describe("if the default size has a max value", () => { beforeEach(() => { - mockParams({ list: "drives", listIndex: "0", partitionId: "/home" }); - mockGetPartition.mockReturnValue({ - mountPath: "/home", - size: { - default: true, - min: gib(5), - max: gib(10), - }, - filesystem: { - default: false, - type: "xfs", - }, - }); + const rangedDefaultHome = { + ...home, + size: { default: true, min: gib(5), max: gib(10) }, + }; + const driveWithDefaultRange = { + ...driveWithHome, + partitions: [swap, rangedDefaultHome], + }; + mockUsePartitionable.mockReturnValue(driveWithDefaultRange); + mockUseConfigModel.mockReturnValue({ drives: [driveWithDefaultRange] }); }); it("allows switching to a custom size", async () => { diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 11d52f46bd..08309e0557 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -48,7 +48,6 @@ import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapp import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; -import AlertOutOfSync from "~/components/core/AlertOutOfSync"; import ResourceNotFound from "~/components/core/ResourceNotFound"; import configModel from "~/model/storage/config-model"; import { useVolumeTemplate, useDevice } from "~/hooks/model/system/storage"; @@ -842,7 +841,6 @@ const PartitionPageForm = () => { -
diff --git a/web/src/components/storage/PartitionsSection.test.tsx b/web/src/components/storage/PartitionsSection.test.tsx index 4cd9f2ff80..be9f592ff1 100644 --- a/web/src/components/storage/PartitionsSection.test.tsx +++ b/web/src/components/storage/PartitionsSection.test.tsx @@ -24,73 +24,40 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; import PartitionsSection from "~/components/storage/PartitionsSection"; -import { apiModel } from "~/api/storage/types"; -import { model } from "~/storage"; +import type { ConfigModel } from "~/model/storage/config-model"; -const partition1: apiModel.Partition = { - mountPath: "/", - size: { - min: 1_000_000_000, - default: true, - }, - filesystem: { default: true, type: "btrfs" }, -}; - -const partition2: apiModel.Partition = { - mountPath: "swap", - size: { - min: 2_000_000_000, - default: false, // false: user provided, true: calculated - }, - filesystem: { default: false, type: "swap" }, -}; - -const drive1Partitions: apiModel.Partition[] = [partition1, partition2]; - -const drive1PartitionsModel: model.Partition[] = [ - { - ...partition1, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, - { - ...partition2, - isNew: true, - isUsed: false, - isReused: false, - isUsedBySpacePolicy: false, - }, -]; - -const drive1: model.Drive = { +const drive: ConfigModel.Drive = { name: "/dev/sda", spacePolicy: "delete", - partitions: drive1PartitionsModel, - list: "drives", - listIndex: 0, - isExplicitBoot: false, - isUsed: true, - isAddingPartitions: true, - isReusingPartitions: true, - isTargetDevice: false, - isBoot: true, - getVolumeGroups: () => [], - getPartition: (path) => drive1PartitionsModel.find((p) => p.mountPath === path), - getMountPaths: () => drive1Partitions.map((p) => p.mountPath), - getConfiguredExistingPartitions: jest.fn(), + partitions: [ + { + mountPath: "/", + size: { + min: 1_000_000_000, + default: true, + }, + filesystem: { default: true, type: "btrfs" }, + }, + { + mountPath: "swap", + size: { + min: 2_000_000_000, + default: false, + }, + filesystem: { default: false, type: "swap" }, + }, + ], }; const mockDeletePartition = jest.fn(); -jest.mock("~/hooks/storage/partition", () => ({ - ...jest.requireActual("~/hooks/storage/partition"), +jest.mock("~/hooks/model/storage/config-model", () => ({ + usePartitionable: () => drive, useDeletePartition: () => mockDeletePartition, })); -async function openMenu(path) { - const { user } = installerRender(); +async function openMenu(path: string) { + const { user } = installerRender(); const detailsButton = screen.getByRole("button", { name: /New partitions/ }); await user.click(detailsButton); @@ -101,18 +68,11 @@ async function openMenu(path) { } describe("PartitionMenuItem", () => { - it("allows users to delete a not required partition", async () => { + it("allows users to delete a partition", async () => { const { user } = await openMenu("swap"); const deleteSwapButton = screen.getByRole("menuitem", { name: "Delete swap" }); await user.click(deleteSwapButton); - expect(mockDeletePartition).toHaveBeenCalled(); - }); - - it("allows users to delete a required partition", async () => { - const { user } = await openMenu("/"); - const deleteRootButton = screen.getByRole("menuitem", { name: "Delete /" }); - await user.click(deleteRootButton); - expect(mockDeletePartition).toHaveBeenCalled(); + expect(mockDeletePartition).toHaveBeenCalledWith("drives", 0, "swap"); }); it("allows users to edit a partition", async () => { diff --git a/web/src/components/storage/ProposalActions.test.tsx b/web/src/components/storage/ProposalActions.test.tsx new file mode 100644 index 0000000000..d21c723293 --- /dev/null +++ b/web/src/components/storage/ProposalActions.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright (c) [2022-2025] 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 { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ProposalActions from "./ProposalActions"; +import type { Storage as Proposal } from "~/model/proposal"; + +const generalAction1: Proposal.Action = { device: 1, text: "format /dev/sda1" }; +const generalAction2: Proposal.Action = { + device: 2, + text: "delete /dev/sdb", + delete: true, +}; +const subvolAction1: Proposal.Action = { + device: 1, + text: "create subvolume @/home", + subvol: true, +}; +const subvolAction2: Proposal.Action = { + device: 1, + text: "delete subvolume @/var", + subvol: true, + delete: true, +}; +const multilineAction: Proposal.Action = { + device: 1, + text: "first line\nsecond line", +}; + +describe("ProposalActions", () => { + it("renders nothing when there are no actions", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders only general actions", () => { + render(); + expect(screen.getByText(generalAction1.text)).toBeInTheDocument(); + expect(screen.getByText(generalAction2.text)).toBeInTheDocument(); + expect(screen.getByText(generalAction2.text).tagName).toBe("STRONG"); + }); + + it("renders multiline actions", () => { + render(); + expect(screen.getByText("first line")).toBeInTheDocument(); + expect(screen.getByText("second line")).toBeInTheDocument(); + }); + + describe("when there are subvolume actions", () => { + it("renders them in a collapsed expandable section", () => { + render(); + expect(screen.getByText(/Show 2 subvolume actions/)).toBeInTheDocument(); + expect(screen.queryByText(subvolAction1.text)).not.toBeVisible(); + expect(screen.queryByText(subvolAction2.text)).not.toBeVisible(); + }); + + it("expands and collapses the section", async () => { + const user = userEvent.setup(); + render(); + + // General action should be visible + expect(screen.getByText(generalAction1.text)).toBeInTheDocument(); + + const toggle = screen.getByText(/Show 2 subvolume actions/); + await user.click(toggle); + + expect(screen.getByText(/Hide 2 subvolume actions/)).toBeInTheDocument(); + expect(screen.getByText(subvolAction1.text)).toBeVisible(); + expect(screen.getByText(subvolAction2.text)).toBeVisible(); + expect(screen.getByText(subvolAction2.text).tagName).toBe("STRONG"); + + await user.click(toggle); + + expect(screen.getByText(/Show 2 subvolume actions/)).toBeInTheDocument(); + expect(screen.queryByText(subvolAction1.text)).not.toBeVisible(); + expect(screen.queryByText(subvolAction2.text)).not.toBeVisible(); + }); + }); +}); diff --git a/web/src/components/storage/ProposalActionsDialog.tsx b/web/src/components/storage/ProposalActions.tsx similarity index 86% rename from web/src/components/storage/ProposalActionsDialog.tsx rename to web/src/components/storage/ProposalActions.tsx index 8aaf83be3d..582e549720 100644 --- a/web/src/components/storage/ProposalActionsDialog.tsx +++ b/web/src/components/storage/ProposalActions.tsx @@ -46,22 +46,15 @@ const ActionsList = ({ actions }: { actions: Proposal.Action[] }) => { return {items}; }; +type ProposalActionsProps = { + actions: Proposal.Action[]; +}; + /** - * Renders a dialog with the given list of actions + * Renders the list of actions to perform in the system. * @component - * - * @param props - * @param [props.actions=[]] - The actions to perform in the system. - * @param [props.isOpen=false] - Whether the dialog is visible or not. - * @param [props.onClose] - Whether the dialog is visible or not. */ -export default function ProposalActionsDialog({ - actions = [], -}: { - actions?: Proposal.Action[]; - isOpen?: boolean; - onClose?: () => void; -}) { +export default function ProposalActions({ actions }: ProposalActionsProps): React.ReactNode { const [isExpanded, setIsExpanded] = useState(false); if (actions.length === 0) return null; diff --git a/web/src/components/storage/ProposalActionsDialog.test.tsx b/web/src/components/storage/ProposalActionsDialog.test.tsx deleted file mode 100644 index 90da556d6d..0000000000 --- a/web/src/components/storage/ProposalActionsDialog.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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, within, waitForElementToBeRemoved } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { ProposalActionsDialog } from "~/components/storage"; -import { Action } from "~/storage"; - -const actions: Action[] = [ - { - text: "Create GPT on /dev/vdc", - device: 1, - subvol: false, - delete: false, - resize: false, - }, - { - text: "Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition", - device: 2, - subvol: false, - delete: false, - resize: false, - }, - { - text: "Create encrypted partition /dev/vdc2 (29.99 GiB) as LVM physical volume", - device: 3, - subvol: false, - delete: false, - resize: false, - }, - { - text: "Create volume group system0 (29.98 GiB) with /dev/mapper/cr_vdc2 (29.99 GiB)", - device: 4, - subvol: false, - delete: false, - resize: false, - }, - { - text: "Create LVM logical volume /dev/system0/root (20.00 GiB) on volume group system0 for / with btrfs", - device: 5, - subvol: false, - delete: false, - resize: false, - }, -]; - -const subvolumeActions: Action[] = [ - { - text: "Create subvolume @ on /dev/system0/root (20.00 GiB)", - device: 100, - subvol: true, - delete: false, - resize: false, - }, - { - text: "Create subvolume @/var on /dev/system0/root (20.00 GiB)", - device: 101, - subvol: true, - delete: false, - resize: false, - }, - { - device: 102, - text: "Create subvolume @/usr/local on /dev/system0/root (20.00 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - text: "Create subvolume @/srv on /dev/system0/root (20.00 GiB)", - device: 103, - subvol: true, - delete: false, - resize: false, - }, - { - text: "Create subvolume @/root on /dev/system0/root (20.00 GiB)", - device: 104, - subvol: true, - delete: false, - resize: false, - }, - { - text: "Create subvolume @/opt on /dev/system0/root (20.00 GiB)", - device: 105, - subvol: true, - delete: false, - resize: false, - }, - { - device: 106, - text: "Create subvolume @/home on /dev/system0/root (20.00 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 107, - text: "Create subvolume @/boot/writable on /dev/system0/root (20.00 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 108, - text: "Create subvolume @/boot/grub2/x86_64-efi on /dev/system0/root (20.00 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 109, - text: "Create subvolume @/boot/grub2/i386-pc on /dev/system0/root (20.00 GiB)", - subvol: true, - delete: false, - resize: false, - }, -]; - -const destructiveAction = { - device: 200, - text: "Delete ext4 on /dev/vdc", - subvol: false, - delete: true, - resize: false, -}; - -const onCloseFn = jest.fn(); - -it("renders nothing by default", () => { - const { container } = plainRender(); - expect(container).toBeEmptyDOMElement(); -}); - -it.skip("renders nothing when isOpen=false", () => { - const { container } = plainRender( - , - ); - expect(container).toBeEmptyDOMElement(); -}); - -describe.skip("when isOpen", () => { - it("renders nothing if there are no actions", () => { - plainRender(); - - expect(screen.queryAllByText(/Delete/)).toEqual([]); - expect(screen.queryAllByText(/Create/)).toEqual([]); - expect(screen.queryAllByText(/Show/)).toEqual([]); - }); - - describe("and there are actions", () => { - it("renders a dialog with the list of actions", () => { - plainRender(); - - const dialog = screen.getByRole("dialog", { name: "Planned Actions" }); - const actionsList = within(dialog).getByRole("list"); - const actionsListItems = within(actionsList).getAllByRole("listitem"); - expect(actionsListItems.map((i) => i.textContent)).toEqual(actions.map((a) => a.text)); - }); - - it("triggers the onClose callback when user clicks the Close button", async () => { - const { user } = plainRender( - , - ); - const closeButton = screen.getByRole("button", { name: "Close" }); - - await user.click(closeButton); - - expect(onCloseFn).toHaveBeenCalled(); - }); - - describe("when there is a destructive action", () => { - it("emphasizes the action", () => { - plainRender( - , - ); - - // https://stackoverflow.com/a/63080940 - const actionItems = screen.getAllByRole("listitem"); - const destructiveActionItem = actionItems.find( - (item) => item.textContent === destructiveAction.text, - ); - - expect(destructiveActionItem).toHaveClass("proposal-action--delete"); - }); - }); - - describe("when there are subvolume actions", () => { - it("does not render the subvolume actions", () => { - plainRender( - , - ); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [genericList, subvolList] = screen.getAllByRole("list", { hidden: true }); - expect(genericList).not.toBeNull(); - expect(subvolList).not.toBeNull(); - const subvolItems = within(subvolList).queryAllByRole("listitem"); - expect(subvolItems).toEqual([]); - }); - - it("renders the subvolume actions after clicking on 'show subvolumes'", async () => { - const { user } = plainRender( - , - ); - - const link = screen.getByText(/Show.*subvolume actions/); - - expect(screen.getAllByRole("list").length).toEqual(1); - - await user.click(link); - - waitForElementToBeRemoved(link); - screen.getByText(/Hide.*subvolume actions/); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [, subvolList] = screen.getAllByRole("list"); - const subvolItems = within(subvolList).getAllByRole("listitem"); - - expect(subvolItems.map((i) => i.textContent)).toEqual(subvolumeActions.map((a) => a.text)); - }); - }); - }); -}); diff --git a/web/src/components/storage/ProposalFailedInfo.test.tsx b/web/src/components/storage/ProposalFailedInfo.test.tsx index 79e0a070b7..79c5b8c463 100644 --- a/web/src/components/storage/ProposalFailedInfo.test.tsx +++ b/web/src/components/storage/ProposalFailedInfo.test.tsx @@ -23,33 +23,10 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; +import type { ConfigModel } from "~/model/storage/config-model"; import ProposalFailedInfo from "./ProposalFailedInfo"; -import { LogicalVolume } from "~/model/storage/config-model/data"; -import { Issue, IssueSeverity, IssueSource } from "~/model/issue"; -import { apiModel } from "~/api/storage/types"; -const mockUseConfigErrorsFn = jest.fn(); -let mockUseIssues = []; - -const configError: Issue = { - description: "Config error", - kind: "storage", - details: "", - source: IssueSource.Config, - severity: IssueSeverity.Error, - scope: "storage", -}; - -const storageIssue: Issue = { - description: "Fake Storage Issue", - details: "", - kind: "storage_issue", - source: IssueSource.Unknown, - severity: IssueSeverity.Error, - scope: "storage", -}; - -const mockApiModel: apiModel.Config = { +const mockFullConfigModel: ConfigModel.Config = { boot: { configure: true, device: { @@ -150,69 +127,87 @@ const mockApiModel: apiModel.Config = { ], }; -jest.mock("~/hooks/storage/api-model", () => ({ - ...jest.requireActual("~/hooks/storage/api-model"), - useApiModel: () => mockApiModel, -})); +const mockCreateNothingConfigModel: ConfigModel.Config = { + boot: { configure: false }, + drives: [ + { + name: "/dev/vdb", + partitions: [ + { + name: "/dev/vdb1", // Has name, so it's not new + size: { default: false, min: 6430916608 }, + }, + ], + }, + ], + volumeGroups: [], +}; -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useConfigErrors: () => mockUseConfigErrorsFn(), - useIssues: () => mockUseIssues, -})); +const mockUseConfigModel = jest.fn(); -// eslint-disable-next-line -const fakeLogicalVolume: LogicalVolume = { - // @ts-expect-error: The #name property is used to distinguish new "devices" - // in the API model, but it is not yet exposed for logical volumes since they - // are currently not reusable. This directive exists to ensure developers - // don't overlook updating the ProposalFailedInfo component in the future, - // when logical volumes become reusable and the #name property is exposed. See - // the FIXME in the ProposalFailedInfo component for more context. - name: "Reusable LV", - lvName: "helpful", -}; +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), +})); describe("ProposalFailedInfo", () => { beforeEach(() => { - mockUseIssues = []; - mockUseConfigErrorsFn.mockReturnValue([]); + mockUseConfigModel.mockReturnValue(mockFullConfigModel); }); - describe("when proposal can't be created due to configuration errors", () => { + describe("when there are no new partitions or logical volumes", () => { beforeEach(() => { - mockUseConfigErrorsFn.mockReturnValue([configError]); + mockUseConfigModel.mockReturnValue(mockCreateNothingConfigModel); }); - it("renders nothing", () => { - const { container } = installerRender(); - expect(container).toBeEmptyDOMElement(); + it("renders a generic warning message", () => { + installerRender(); + screen.getByText("Warning alert:"); + screen.getByText("Failed to calculate a storage layout"); + screen.getByText(/It is not possible to install the system with the current configuration/); }); }); - describe("when proposal is valid", () => { - describe("and has no errors", () => { - beforeEach(() => { - mockUseIssues = []; + describe("when there are new partitions or logical volumes", () => { + describe("and boot is configured", () => { + it("renders a warning mentioning boot partition", () => { + installerRender(); + screen.getByText("Warning alert:"); + screen.getByText("Failed to calculate a storage layout"); + screen.getByText(/It is not possible to allocate space for the boot partition and for/); }); - it("renders nothing", () => { - const { container } = installerRender(); - expect(container).toBeEmptyDOMElement(); + it("displays the mount paths with sizes", () => { + installerRender(); + // Should show mount paths for new partitions and logical volumes + expect(screen.getByText(/\/documents/)).toBeInTheDocument(); + expect(screen.getByText(/\//)).toBeInTheDocument(); + expect(screen.getByText(/swap/)).toBeInTheDocument(); }); }); - describe("but has errors", () => { + describe("and boot is not configured", () => { beforeEach(() => { - mockUseIssues = [storageIssue]; + mockUseConfigModel.mockReturnValue({ + ...mockFullConfigModel, + boot: { configure: false }, + }); }); - it("renders a warning alert with hints about the failure", () => { + it("renders a warning without mentioning boot partition", () => { installerRender(); screen.getByText("Warning alert:"); screen.getByText("Failed to calculate a storage layout"); screen.getByText(/It is not possible to allocate space for/); + expect(screen.queryByText(/boot partition/)).not.toBeInTheDocument(); }); }); }); + + describe("helper text", () => { + it("always shows adjustment guidance", () => { + installerRender(); + screen.getByText(/Adjust the settings below/); + }); + }); }); diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 23b328daca..342ed8a246 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -29,64 +29,67 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import ProposalPage from "~/components/storage/ProposalPage"; -import { StorageDevice } from "~/storage"; -import { Issue, IssueSeverity, IssueSource } from "~/model/issue"; +import type { Storage } from "~/model/proposal"; +import type { Issue } from "~/model/issue"; -const disk: StorageDevice = { +const disk: Storage.Device = { sid: 60, - type: "disk", - isDrive: true, - description: "", - vendor: "Seagate", - model: "Unknown", - driver: ["ahci", "mmcblk"], - bus: "IDE", + class: "drive", name: "/dev/vda", - size: 1e6, + description: "Seagate disk", + drive: { driver: ["ahci", "mmcblk"], bus: "IDE" }, + block: { start: 1, size: 1e6, shrinking: { supported: false } }, }; -const systemError: Issue = { - description: "System error", - kind: "storage", - details: "", - source: IssueSource.System, - severity: IssueSeverity.Error, +const proposalIssue: Issue = { + description: "No proposal", + class: "proposal", scope: "storage", }; -const configError: Issue = { +const configFixableIssue: Issue = { + description: "No root", + class: "configNoRoot", + scope: "storage", +}; + +const configUnfixableIssue: Issue = { description: "Config error", - kind: "storage", - details: "", - source: IssueSource.Config, - severity: IssueSeverity.Error, + class: "something", scope: "storage", }; const mockUseAvailableDevices = jest.fn(); -const mockUseResetConfigMutation = jest.fn(); -const mockUseDeprecated = jest.fn(); -const mockUseDeprecatedChanges = jest.fn(); -const mockUseReprobeMutation = jest.fn(); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useResetConfigMutation: () => mockUseResetConfigMutation(), - useDeprecated: () => mockUseDeprecated(), - useDeprecatedChanges: () => mockUseDeprecatedChanges(), - useReprobeMutation: () => mockUseReprobeMutation(), -})); +const mockUseReset = jest.fn(); +const mockUseConfigModel = jest.fn(); +const mockUseProposal = jest.fn(); +const mockUseIssues = jest.fn(); -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), useAvailableDevices: () => mockUseAvailableDevices(), })); -const mockUseConfigModel = jest.fn(); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), +jest.mock("~/hooks/model/config/storage", () => ({ + ...jest.requireActual("~/hooks/model/config/storage"), + useReset: () => mockUseReset(), +})); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), useConfigModel: () => mockUseConfigModel(), })); +jest.mock("~/hooks/model/proposal/storage", () => ({ + ...jest.requireActual("~/hooks/model/proposal/storage"), + useProposal: () => mockUseProposal(), +})); + +jest.mock("~/hooks/model/issue", () => ({ + ...jest.requireActual("~/hooks/model/issue"), + useIssues: () => mockUseIssues(), +})); + const mockUseZFCPSupported = jest.fn(); jest.mock("~/queries/storage/zfcp", () => ({ ...jest.requireActual("~/queries/storage/zfcp"), @@ -99,17 +102,10 @@ jest.mock("~/queries/storage/dasd", () => ({ useDASDSupported: () => mockUseDASDSupported(), })); -const mockUseSystemErrors = jest.fn(); -const mockUseConfigErrors = jest.fn(); -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useSystemErrors: () => mockUseSystemErrors(), - useConfigErrors: () => mockUseConfigErrors(), -})); - jest.mock("./ProposalTransactionalInfo", () => () =>
trasactional info
); -jest.mock("./ProposalFailedInfo", () => () =>
failed info
); -jest.mock("./UnsupportedModelInfo", () => () =>
unsupported info
); +jest.mock("./ProposalFailedInfo", () => () =>
proposal failed info
); +jest.mock("./UnsupportedModelInfo", () => () =>
unsupported model info
); +jest.mock("./FixableConfigInfo", () => () =>
fixable config info
); jest.mock("./ProposalResultSection", () => () =>
result
); jest.mock("./ConfigEditor", () => () =>
installation devices
); jest.mock("./EncryptionSection", () => () =>
encryption section
); @@ -119,14 +115,13 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( )); beforeEach(() => { - mockUseResetConfigMutation.mockReturnValue({ mutate: jest.fn() }); - mockUseReprobeMutation.mockReturnValue({ mutateAsync: jest.fn() }); - mockUseDeprecated.mockReturnValue(false); - mockUseSystemErrors.mockReturnValue([]); - mockUseConfigErrors.mockReturnValue([]); + mockUseReset.mockReturnValue(jest.fn()); + mockUseIssues.mockReturnValue([]); + mockUseProposal.mockReturnValue(null); + mockUseConfigModel.mockReturnValue({ drives: [] }); }); -describe("if there are not devices", () => { +describe("if there are no devices", () => { beforeEach(() => { mockUseAvailableDevices.mockReturnValue([]); }); @@ -191,15 +186,20 @@ describe("if there are not devices", () => { }); }); -describe("if there is not a model", () => { +describe("if the UI does not support the current configuration (no model)", () => { beforeEach(() => { mockUseAvailableDevices.mockReturnValue([disk]); mockUseConfigModel.mockReturnValue(null); }); - describe("and there are system errors", () => { + describe("and there are unfixable config errors", () => { beforeEach(() => { - mockUseSystemErrors.mockReturnValue([systemError]); + mockUseIssues.mockReturnValue([configUnfixableIssue]); + }); + + it("renders a text explaining the settings are wrong", () => { + installerRender(); + expect(screen.queryByText("Invalid storage settings")).toBeInTheDocument(); }); it("renders an option for resetting the config", () => { @@ -218,14 +218,19 @@ describe("if there is not a model", () => { }); }); - describe("and there are not system errors", () => { + describe("and there are config errors but all of them are fixable", () => { beforeEach(() => { - mockUseSystemErrors.mockReturnValue([]); + mockUseIssues.mockReturnValue([configFixableIssue]); }); - it("renders an unsupported model alert", async () => { + it("renders a text explaining the settings are wrong", () => { installerRender(); - expect(screen.queryByText("unsupported info")).toBeInTheDocument(); + expect(screen.queryByText("Invalid storage settings")).toBeInTheDocument(); + }); + + it("renders an option for resetting the config", () => { + installerRender(); + expect(screen.queryByRole("button", { name: /Reset/ })).toBeInTheDocument(); }); it("does not render the installation devices", async () => { @@ -233,6 +238,55 @@ describe("if there is not a model", () => { expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); }); + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and there are no config errors but the proposal failed", () => { + beforeEach(() => { + mockUseIssues.mockReturnValue([proposalIssue]); + mockUseProposal.mockReturnValue(null); + }); + + it("renders a text explaining the settings cannot be adjusted", () => { + installerRender(); + expect(screen.queryByText("Unable to modify the settings")).toBeInTheDocument(); + }); + + it("renders an option for resetting the config", () => { + installerRender(); + expect(screen.queryByRole("button", { name: /Reset/ })).toBeInTheDocument(); + }); + + it("does not render the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and the proposal succeeded", () => { + beforeEach(() => { + mockUseIssues.mockReturnValue([]); + mockUseProposal.mockReturnValue({ devices: [], actions: [] }); + }); + + it("renders an info block explaining the settings cannot be adjusted", () => { + installerRender(); + expect(screen.queryByText("unsupported model info")).toBeInTheDocument(); + }); + + it("does not render the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + it("renders the result", () => { installerRender(); expect(screen.queryByText("result")).toBeInTheDocument(); @@ -240,21 +294,20 @@ describe("if there is not a model", () => { }); }); -describe("if there is a model", () => { +describe("if the UI supports the configuration (there is a model)", () => { beforeEach(() => { mockUseAvailableDevices.mockReturnValue([disk]); mockUseConfigModel.mockReturnValue({ drives: [] }); }); - describe("and there are config errors and system errors", () => { + describe("and there are unfixable config errors", () => { beforeEach(() => { - mockUseConfigErrors.mockReturnValue([configError]); - mockUseSystemErrors.mockReturnValue([systemError]); + mockUseIssues.mockReturnValue([configUnfixableIssue]); }); - it("renders the config errors", () => { + it("renders a text explaining the settings are wrong", () => { installerRender(); - expect(screen.queryByText("Config error")).toBeInTheDocument(); + expect(screen.queryByText("Invalid storage settings")).toBeInTheDocument(); }); it("renders an option for resetting the config", () => { @@ -273,14 +326,36 @@ describe("if there is a model", () => { }); }); - describe("and there are not config errors but there are system errors", () => { + describe("and there are config errors but all of them are fixable", () => { + beforeEach(() => { + mockUseIssues.mockReturnValue([configFixableIssue]); + }); + + it("renders an info block explaining the settings must be fixed", () => { + installerRender(); + expect(screen.queryByText("fixable config info")).toBeInTheDocument(); + }); + + it("renders the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and there are no config errors but the proposal failed", () => { beforeEach(() => { - mockUseSystemErrors.mockReturnValue([systemError]); + mockUseIssues.mockReturnValue([proposalIssue]); + mockUseProposal.mockReturnValue(null); }); - it("renders a failed proposal failed", () => { + it("renders an info block explaining the proposal failed", () => { installerRender(); - expect(screen.queryByText("failed info")).toBeInTheDocument(); + expect(screen.queryByText("proposal failed info")).toBeInTheDocument(); }); it("renders the installation devices", () => { @@ -294,10 +369,10 @@ describe("if there is a model", () => { }); }); - describe("and there are neither config errors nor system errors", () => { + describe("and the proposal succeeded", () => { beforeEach(() => { - mockUseSystemErrors.mockReturnValue([]); - mockUseConfigErrors.mockReturnValue([]); + mockUseIssues.mockReturnValue([]); + mockUseProposal.mockReturnValue({ devices: [], actions: [] }); }); it("renders the installation devices", () => { diff --git a/web/src/components/storage/ProposalResultSection.test.tsx b/web/src/components/storage/ProposalResultSection.test.tsx index 502c1f6d49..00a8056c96 100644 --- a/web/src/components/storage/ProposalResultSection.test.tsx +++ b/web/src/components/storage/ProposalResultSection.test.tsx @@ -21,33 +21,64 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { ProposalResultSection } from "~/components/storage"; -import { devices, actions } from "./test-data/full-result-example"; +import type { Storage as System } from "~/model/system"; +import type { Storage as Proposal } from "~/model/proposal"; -const mockUseActionsFn = jest.fn(); -const mockConfig = { drives: [] }; +jest.mock("~/components/storage/ProposalResultTable", () => () =>
result table
); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => devices.staging, - useActions: () => mockUseActionsFn(), +const mockFlattenDevices = jest.fn(); + +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useFlattenDevices: () => mockFlattenDevices(), })); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useConfigModel: () => mockConfig, +const mockActions = jest.fn(); + +jest.mock("~/hooks/model/proposal/storage", () => ({ + ...jest.requireActual("~/hooks/model/proposal/storage"), + useFlattenDevices: () => [], + useActions: () => mockActions(), })); +const systemDevices: System.Device[] = [ + { + sid: 83, + name: "/dev/vda", + class: "drive", + }, +]; + +const actions: Proposal.Action[] = [ + { + device: 78, + text: "", + delete: true, + }, + { + device: 79, + text: "", + delete: false, + }, + { + device: 80, + text: "", + delete: true, + }, +]; + describe("ProposalResultSection", () => { beforeEach(() => { - mockUseActionsFn.mockReturnValue(actions); + mockFlattenDevices.mockReturnValue(systemDevices); + mockActions.mockReturnValue(actions); }); describe("when there are no delete actions", () => { beforeEach(() => { - mockUseActionsFn.mockReturnValue(actions.filter((a) => !a.delete)); + mockActions.mockReturnValue(actions.filter((a) => !a.delete)); }); it("does not render a warning when there are not delete actions", () => { @@ -58,11 +89,21 @@ describe("ProposalResultSection", () => { describe("when there are delete actions affecting a previous system", () => { beforeEach(() => { - // NOTE: simulate the deletion of vdc2 (sid: 79) for checking that - // affected systems are rendered in the warning summary - mockUseActionsFn.mockReturnValue([ - { device: 79, subvol: false, delete: true, resize: false, text: "" }, + // NOTE: simulate the deletion of vdc2 for checking that affected systems are rendered in the + // warning summary. + mockFlattenDevices.mockReturnValue([ + { + sid: 79, + name: "/dev/vda1", + class: "partition", + block: { + start: 0, + size: 1024, + systems: ["openSUSE"], + }, + } as System.Device, ]); + mockActions.mockReturnValue([{ device: 79, delete: true, text: "" }]); }); it("renders the affected systems in the deletion reminder, if any", () => { @@ -73,46 +114,14 @@ describe("ProposalResultSection", () => { it("renders a reminder about the delete actions", () => { installerRender(); - expect(screen.queryByText(/4 destructive/)).toBeInTheDocument(); + expect(screen.queryByText(/2 destructive/)).toBeInTheDocument(); }); - it("renders a treegrid including all relevant information about final result", async () => { + it("renders the final layout", async () => { const { user } = installerRender(); const tab = screen.getByRole("tab", { name: /Final layout/ }); await user.click(tab); - const treegrid = screen.getByRole("treegrid"); - /** - * Expected rows for full-result-example - * -------------------------------------------------- - * "/dev/vdc Disk GPT 30 GiB" - * "vdc1 BIOS Boot Partition 8 MiB" - * "vdc3 swap Swap Partition 1.5 GiB" - * "Unused space 3.49 GiB" - * "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" - * "Unused space 1 GiB" - * "vdc4 Linux Before 2 GiB 1.5 GiB" - * "vdc5 / New Btrfs Partition 17.5 GiB" - * - * Device Mount point Details Size - * ------------------------------------------------------------------------- - * /dev/vdc Disk GPT 30 GiB - * vdc1 BIOS Boot Partition 8 MiB - * vdc3 swap Swap Partition 1.5 GiB - * Unused space 3.49 GiB - * vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB - * Unused space 1 GiB - * vdc4 Linux 1.5 GiB - * vdc5 / Btrfs Partition 17.5 GiB - * ------------------------------------------------------------------------- - */ - within(treegrid).getByRole("row", { name: "/dev/vdc Disk GPT 30 GiB" }); - within(treegrid).getByRole("row", { name: "vdc1 BIOS Boot Partition 8 MiB" }); - within(treegrid).getByRole("row", { name: "vdc3 swap Swap Partition 1.5 GiB" }); - within(treegrid).getByRole("row", { name: "Unused space 3.49 GiB" }); - within(treegrid).getByRole("row", { name: "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" }); - within(treegrid).getByRole("row", { name: "Unused space 1 GiB" }); - within(treegrid).getByRole("row", { name: "vdc4 Linux 1.5 GiB" }); - within(treegrid).getByRole("row", { name: "vdc5 / Btrfs Partition 17.5 GiB" }); + expect(screen.queryByText(/result table/)).toBeInTheDocument(); }); }); diff --git a/web/src/components/storage/ProposalResultSection.tsx b/web/src/components/storage/ProposalResultSection.tsx index c674478eb4..5e1f8c3924 100644 --- a/web/src/components/storage/ProposalResultSection.tsx +++ b/web/src/components/storage/ProposalResultSection.tsx @@ -26,7 +26,7 @@ import SmallWarning from "~/components/core/SmallWarning"; import { Page, NestedContent } from "~/components/core"; import DevicesManager from "~/model/storage/devices-manager"; import ProposalResultTable from "~/components/storage/ProposalResultTable"; -import { ProposalActionsDialog } from "~/components/storage"; +import ProposalActions from "~/components/storage/ProposalActions"; import { _, n_, formatList } from "~/i18n"; import { useFlattenDevices as useSystemFlattenDevices } from "~/hooks/model/system/storage"; import { @@ -100,7 +100,7 @@ function ActionsList({ manager }: ActionsListProps) { return ( - + ); } diff --git a/web/src/components/storage/ProposalResultTable.test.tsx b/web/src/components/storage/ProposalResultTable.test.tsx new file mode 100644 index 0000000000..82661e48b2 --- /dev/null +++ b/web/src/components/storage/ProposalResultTable.test.tsx @@ -0,0 +1,360 @@ +/* + * Copyright (c) [2025] 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 { render, screen, within } from "@testing-library/react"; +import ProposalResultTable from "~/components/storage/ProposalResultTable"; +import DevicesManager from "~/model/storage/devices-manager"; +import type { Storage as System } from "~/model/system"; +import type { Storage as Proposal } from "~/model/proposal"; + +const systemDevices: System.Device[] = [ + { + sid: 70, + name: "/dev/vdc", + description: "Disk", + class: "drive", + block: { + active: true, + encrypted: false, + start: 0, + size: 32212254720, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + partitionTable: { + type: "gpt", + unusedSlots: [ + { + start: 27265024, + size: 18252545536, + }, + ], + }, + partitions: [ + { + sid: 78, + name: "/dev/vdc1", + class: "partition", + block: { + active: true, + encrypted: false, + start: 2048, + size: 5368709120, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + }, + { + sid: 79, + name: "/dev/vdc2", + class: "partition", + block: { + active: true, + encrypted: false, + start: 10487808, + size: 5368709120, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: ["openSUSE Leap 15.2", "Fedora 10.30"], + }, + }, + { + sid: 80, + name: "/dev/vdc3", + description: "XFS Partition", + class: "partition", + block: { + active: true, + encrypted: false, + start: 20973568, + size: 1073741824, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + filesystem: { + sid: 92, + type: "xfs", + }, + }, + { + sid: 81, + name: "/dev/vdc4", + description: "Linux", + class: "partition", + block: { + active: true, + encrypted: false, + start: 23070720, + size: 2147483648, + shrinking: { + supported: true, + minSize: 2147483136, + }, + systems: [], + }, + }, + ], + }, +]; + +const proposalDevices: Proposal.Device[] = [ + { + sid: 70, + name: "/dev/vdc", + description: "Disk", + class: "drive", + block: { + active: true, + encrypted: false, + start: 0, + size: 32212254720, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + partitionTable: { + type: "gpt", + unusedSlots: [ + { + start: 3164160, + size: 3749707776, + }, + { + start: 20973568, + size: 1073741824, + }, + ], + }, + partitions: [ + { + sid: 79, + name: "/dev/vdc2", + description: "Linux RAID", + class: "partition", + block: { + active: true, + encrypted: false, + start: 10487808, + size: 5368709120, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: ["openSUSE Leap 15.2", "Fedora 10.30"], + }, + }, + { + sid: 81, + name: "/dev/vdc4", + description: "Linux", + class: "partition", + block: { + active: true, + encrypted: false, + start: 23070720, + size: 1608515584, + shrinking: { + supported: true, + minSize: 2147483136, + }, + systems: [], + }, + }, + { + sid: 459, + name: "/dev/vdc1", + description: "BIOS Boot Partition", + class: "partition", + block: { + active: true, + encrypted: false, + start: 2048, + size: 8388608, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + }, + { + sid: 460, + name: "/dev/vdc3", + description: "Swap Partition", + class: "partition", + block: { + active: true, + encrypted: false, + start: 18432, + size: 1610612736, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + filesystem: { + sid: 461, + type: "swap", + mountPath: "swap", + }, + }, + { + sid: 463, + name: "/dev/vdc5", + description: "Btrfs Partition", + class: "partition", + block: { + active: true, + encrypted: false, + start: 26212352, + size: 18791513600, + shrinking: { + supported: false, + reasons: ["Resizing is not supported"], + }, + systems: [], + }, + filesystem: { + sid: 464, + type: "btrfs", + mountPath: "/", + }, + }, + ], + }, +]; + +const actions: Proposal.Action[] = [ + { + device: 80, + text: "Delete partition /dev/vdc3 (1.00 GiB)", + subvol: false, + delete: true, + resize: false, + }, + { + device: 78, + text: "Delete partition /dev/vdc1 (5.00 GiB)", + subvol: false, + delete: true, + resize: false, + }, + { + device: 81, + text: "Shrink partition /dev/vdc4 from 2.00 GiB to 1.50 GiB", + subvol: false, + delete: false, + resize: true, + }, + { + device: 459, + text: "Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition", + subvol: false, + delete: false, + resize: false, + }, + { + device: 460, + text: "Create partition /dev/vdc3 (1.50 GiB) for swap", + subvol: false, + delete: false, + resize: false, + }, + { + device: 463, + text: "Create partition /dev/vdc5 (17.50 GiB) for / with btrfs", + subvol: false, + delete: false, + resize: false, + }, +]; + +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => {}, +})); + +type Device = System.Device | Proposal.Device; + +function flatDevices(devices: Device[]): Device[] { + const partitions = devices.flatMap((d) => d.partitions); + return [devices, partitions].flat(); +} + +describe("ProposalResultTable", () => { + it("renders the relevant information about final result", async () => { + const devicesManager = new DevicesManager( + flatDevices(systemDevices), + flatDevices(proposalDevices), + actions, + ); + render(); + const treegrid = screen.getByRole("treegrid"); + /** + * Expected rows for full-result-example + * -------------------------------------------------- + * "/dev/vdc Disk GPT 30 GiB" + * "vdc1 BIOS Boot Partition 8 MiB" + * "vdc3 swap Swap Partition 1.5 GiB" + * "Unused space 3.49 GiB" + * "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" + * "Unused space 1 GiB" + * "vdc4 Linux Before 2 GiB 1.5 GiB" + * "vdc5 / New Btrfs Partition 17.5 GiB" + * + * Device Mount point Details Size + * ------------------------------------------------------------------------- + * /dev/vdc Disk GPT 30 GiB + * vdc1 BIOS Boot Partition 8 MiB + * vdc3 swap Swap Partition 1.5 GiB + * Unused space 3.49 GiB + * vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB + * Unused space 1 GiB + * vdc4 Linux 1.5 GiB + * vdc5 / Btrfs Partition 17.5 GiB + * ------------------------------------------------------------------------- + */ + within(treegrid).getByRole("row", { name: "/dev/vdc Disk GPT 30 GiB" }); + within(treegrid).getByRole("row", { name: "vdc1 BIOS Boot Partition New 8 MiB" }); + within(treegrid).getByRole("row", { name: "vdc3 swap Swap Partition New 1.5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 3.49 GiB" }); + within(treegrid).getByRole("row", { name: "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 1 GiB" }); + within(treegrid).getByRole("row", { name: "vdc4 Linux 1.5 GiB Before 2 GiB" }); + within(treegrid).getByRole("row", { name: "vdc5 / Btrfs Partition New 17.5 GiB" }); + }); +}); diff --git a/web/src/components/storage/ProposalResultTable.tsx b/web/src/components/storage/ProposalResultTable.tsx index b8b33cf918..928e91a7ce 100644 --- a/web/src/components/storage/ProposalResultTable.tsx +++ b/web/src/components/storage/ProposalResultTable.tsx @@ -148,7 +148,7 @@ type ProposalResultTableProps = { */ export default function ProposalResultTable({ devicesManager }: ProposalResultTableProps) { const model = useConfigModel(); - const devices = devicesManager.usedDevices(model?.drives.map((d) => d.name) || []); + const devices = devicesManager.usedDevices(model?.drives?.map((d) => d.name) || []); return ( ({ - ...jest.requireActual("~/queries/software"), - useProduct: () => ({ - selectedProduct: { name: "Test" }, - }), - useProductChanges: () => jest.fn(), +jest.mock("~/hooks/model/config", () => ({ + ...jest.requireActual("~/hooks/model/config"), + useProduct: () => ({ name: "Test" }), })); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useVolumes: () => mockVolumes, +const mockVolumeTemplates = jest.fn(); + +jest.mock("~/hooks/model/system/storage", () => ({ + ...jest.requireActual("~/hooks/model/system/storage"), + useVolumeTemplates: () => mockVolumeTemplates(), })); -const rootVolume: Volume = { +const rootVolume: System.Volume = { mountPath: "/", mountOptions: [], - target: "default", - fsType: "Btrfs", + autoSize: false, minSize: 1024, maxSize: 2048, - autoSize: false, + fsType: "btrfs", snapshots: false, transactional: false, outline: { required: true, - fsTypes: ["Btrfs", "Ext4"], + fsTypes: ["btrfs", "ext4"], supportAutoSize: true, snapshotsConfigurable: true, snapshotsAffectSizes: true, @@ -63,7 +60,7 @@ const rootVolume: Volume = { describe("if the system is not transactional", () => { beforeEach(() => { - mockVolumes = [rootVolume]; + mockVolumeTemplates.mockReturnValue([rootVolume]); }); it("renders nothing", () => { @@ -74,7 +71,7 @@ describe("if the system is not transactional", () => { describe("if the system is transactional", () => { beforeEach(() => { - mockVolumes = [{ ...rootVolume, transactional: true }]; + mockVolumeTemplates.mockReturnValue([{ ...rootVolume, transactional: true }]); }); it("renders an explanation about the transactional system", () => { diff --git a/web/src/components/storage/SpaceActionsTable.test.tsx b/web/src/components/storage/SpaceActionsTable.test.tsx index 486277a3cc..0993c99833 100644 --- a/web/src/components/storage/SpaceActionsTable.test.tsx +++ b/web/src/components/storage/SpaceActionsTable.test.tsx @@ -24,77 +24,58 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { deviceChildren, gib } from "~/components/storage/utils"; +import { gib } from "~/components/storage/utils"; import { plainRender } from "~/test-utils"; +import type { Storage } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; import SpaceActionsTable, { SpaceActionsTableProps } from "~/components/storage/SpaceActionsTable"; -import { StorageDevice } from "~/storage"; -import { apiModel } from "~/api/storage/types"; - -const sda: StorageDevice = { - sid: 59, - isDrive: true, - type: "disk", - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: gib(10), - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; -const sda1: StorageDevice = { +const sda1: Storage.Device = { sid: 69, + class: "partition", name: "/dev/sda1", description: "Swap partition", - isDrive: false, - type: "partition", - size: gib(2), - shrinking: { unsupported: ["Resizing is not supported"] }, - start: 1, + block: { + start: 1, + size: gib(2), + shrinking: { supported: false, reasons: ["Resizing is not supported"] }, + }, }; -const sda2: StorageDevice = { +const sda2: Storage.Device = { sid: 79, name: "/dev/sda2", + class: "partition", description: "EXT4 partition", - isDrive: false, - type: "partition", - size: gib(6), - shrinking: { supported: gib(3) }, - start: 2, + block: { + start: gib(2), + size: gib(6), + shrinking: { supported: true, minSize: gib(3) }, + }, }; -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 0, - unusedSlots: [{ start: 3, size: gib(2) }], +const slot: Storage.UnusedSlot = { + start: gib(8), + size: gib(2), }; -const mockDrive: apiModel.Drive = { +const devices: (Storage.Device | Storage.UnusedSlot)[] = [sda1, sda2, slot]; + +const driveWithReused: ConfigModel.Drive = { name: "/dev/sda", partitions: [ { name: "/dev/sda2", mountPath: "swap", - filesystem: { reuse: false, default: true }, + filesystem: { default: true }, }, ], }; -const mockUseConfigModelFn = jest.fn(); -jest.mock("~/queries/storage/config-model", () => ({ - useConfigModel: () => mockUseConfigModelFn(), +const mockUseConfigModel = jest.fn(); +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), })); /** @@ -114,12 +95,12 @@ let props: SpaceActionsTableProps; describe("SpaceActionsTable", () => { beforeEach(() => { props = { - devices: deviceChildren(sda), + devices, deviceAction, onActionChange: jest.fn(), }; - mockUseConfigModelFn.mockReturnValue({ drives: [] }); + mockUseConfigModel.mockReturnValue({ drives: [] }); }); it("shows the devices to configure the space actions", () => { @@ -162,7 +143,7 @@ describe("SpaceActionsTable", () => { describe("if a partition is going to be used", () => { beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [mockDrive] }); + mockUseConfigModel.mockReturnValue({ drives: [driveWithReused] }); }); it("disables shrink and delete actions for the partition", () => { diff --git a/web/src/components/storage/SpacePolicyMenu.test.tsx b/web/src/components/storage/SpacePolicyMenu.test.tsx new file mode 100644 index 0000000000..b307de704b --- /dev/null +++ b/web/src/components/storage/SpacePolicyMenu.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright (c) [2025] 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, waitFor } from "@testing-library/react"; +import { installerRender, mockNavigateFn } from "~/test-utils"; +import SpacePolicyMenu from "./SpacePolicyMenu"; +import type { Storage as System } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; + +const mockUseNavigate = jest.fn(); + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useNavigate: () => mockUseNavigate(), +})); + +const mockSystemDevice = jest.fn(); + +jest.mock("~/hooks/model/system/storage", () => ({ + useDevice: () => mockSystemDevice(), +})); + +const mockConfigModel = jest.fn(); +const mockPartitionable = jest.fn(); +const mockSetSpacePolicy = jest.fn(); + +jest.mock("~/hooks/model/storage/config-model", () => ({ + useConfigModel: () => mockConfigModel(), + usePartitionable: () => mockPartitionable(), + useSetSpacePolicy: () => mockSetSpacePolicy, +})); + +const vda: System.Device = { + sid: 1, + class: "drive", + name: "/dev/vda", + partitions: [{ sid: 10, name: "/dev/vda1" }], +}; + +const deviceModel: ConfigModel.Drive = { + name: "/dev/vda", + spacePolicy: "delete", +}; + +describe("SpacePolicyMenu", () => { + beforeEach(() => { + mockSystemDevice.mockReturnValue(vda); + mockConfigModel.mockReturnValue({ drives: [deviceModel] }); + mockPartitionable.mockReturnValue(deviceModel); + }); + + it("should render the SpacePolicyMenu with correct initial state", async () => { + const { user } = installerRender(); + + const toggleButton = screen.getByRole("button", { name: "All content will be deleted" }); + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton).toHaveAttribute("aria-expanded", "false"); // Initially closed + + await user.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Delete current content")).toBeInTheDocument(); + }); + }); + + it("should not render the SpacePolicyMenu if existingPartitions is empty", () => { + mockSystemDevice.mockReturnValue({ ...vda, partitions: [] }); + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should call setSpacePolicy when a non-custom policy is selected", async () => { + const { user } = installerRender(); + + const toggleButton = screen.getByRole("button", { name: "All content will be deleted" }); + await user.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + }); + + const keepPolicyItem = screen.getByRole("menuitem", { name: /Use available space/ }); + await user.click(keepPolicyItem); + + expect(mockSetSpacePolicy).toHaveBeenCalledWith("drives", 0, { type: "keep" }); + expect(mockNavigateFn).not.toHaveBeenCalled(); + }); + + it("should navigate to editSpacePolicy when 'Custom' policy is selected", async () => { + const { user } = installerRender(); + + const toggleButton = screen.getByRole("button", { name: "All content will be deleted" }); + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + }); + + const customPolicyItem = screen.getByRole("menuitem", { name: /Custom/ }); + await user.click(customPolicyItem); + + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/drives/0/space-policy/edit"); + expect(mockSetSpacePolicy).not.toHaveBeenCalled(); + }); + + it("should mark the current policy as selected", async () => { + const { user } = installerRender(); + + const toggleButton = screen.getByRole("button", { name: "All content will be deleted" }); + await user.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + }); + + const deletePolicyItem = screen.getByRole("menuitem", { name: /Delete/ }); + expect(deletePolicyItem).toHaveClass("pf-m-selected"); + }); +}); diff --git a/web/src/components/storage/UnsupportedModelInfo.test.tsx b/web/src/components/storage/UnsupportedModelInfo.test.tsx index 434a0645e0..a1c4a72a4e 100644 --- a/web/src/components/storage/UnsupportedModelInfo.test.tsx +++ b/web/src/components/storage/UnsupportedModelInfo.test.tsx @@ -25,25 +25,23 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import UnsupportedModelInfo from "./UnsupportedModelInfo"; -const mockUseResetConfigMutation = jest.fn(); -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useResetConfigMutation: () => mockUseResetConfigMutation(), -})); +const mockConfigModel = jest.fn(); -const mockUseConfigModel = jest.fn(); -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useConfigModel: () => mockUseConfigModel(), +jest.mock("~/hooks/model/storage/config-model", () => ({ + ...jest.requireActual("~/hooks/model/storage/config-model"), + useConfigModel: () => mockConfigModel(), })); -beforeEach(() => { - mockUseResetConfigMutation.mockReturnValue({ mutate: jest.fn() }); -}); +const mockReset = jest.fn(); + +jest.mock("~/hooks/model/config/storage", () => ({ + ...jest.requireActual("~/hooks/model/config/storage"), + useReset: () => mockReset, +})); -describe("if there is not a model", () => { +describe("if there is not config model", () => { beforeEach(() => { - mockUseConfigModel.mockReturnValue(null); + mockConfigModel.mockReturnValue(null); }); it("renders an alert", () => { @@ -57,9 +55,9 @@ describe("if there is not a model", () => { }); }); -describe("if there is a model", () => { +describe("if there is config model", () => { beforeEach(() => { - mockUseConfigModel.mockReturnValue({ drives: [] }); + mockConfigModel.mockReturnValue({ drives: [] }); }); it("does not renders an alert", () => { diff --git a/web/src/components/storage/UnsupportedModelInfo.tsx b/web/src/components/storage/UnsupportedModelInfo.tsx index 43b5ad92ba..aa61952710 100644 --- a/web/src/components/storage/UnsupportedModelInfo.tsx +++ b/web/src/components/storage/UnsupportedModelInfo.tsx @@ -30,10 +30,10 @@ import { useReset } from "~/hooks/model/config/storage"; * Info about unsupported model. */ export default function UnsupportedModelInfo(): React.ReactNode { - const model = useConfigModel(); + const config = useConfigModel(); const reset = useReset(); - if (model) return null; + if (config) return null; return ( diff --git a/web/src/components/storage/UnusedMenu.test.tsx b/web/src/components/storage/UnusedMenu.test.tsx new file mode 100644 index 0000000000..d700b40f6d --- /dev/null +++ b/web/src/components/storage/UnusedMenu.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2025] 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 { installerRender as render, mockNavigateFn } from "~/test-utils"; +import UnusedMenu from "./UnusedMenu"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { generateEncodedPath } from "~/utils"; + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useNavigate: () => mockNavigateFn, +})); + +describe("UnusedMenu", () => { + it("should render the toggle button with description", () => { + render(); + expect(screen.getByRole("button", { name: "Not configured yet" })).toBeInTheDocument(); + }); + + it("should open the menu when the toggle is clicked", async () => { + const { user } = render(); + const toggleButton = screen.getByRole("button", { name: "Not configured yet" }); + + await user.click(toggleButton); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByText("Add or use partition")).toBeInTheDocument(); + expect( + screen.getByText("Format the whole device or mount an existing file system"), + ).toBeInTheDocument(); + }); + + it("should navigate to add partition path when 'Add or use partition' is clicked", async () => { + const { user } = render(); + const toggleButton = screen.getByRole("button", { name: "Not configured yet" }); + + await user.click(toggleButton); + + const addPartitionItem = screen.getByText("Add or use partition"); + await user.click(addPartitionItem); + + const expectedPath = generateEncodedPath(PATHS.addPartition, { + collection: "drives", + index: 0, + }); + expect(mockNavigateFn).toHaveBeenCalledWith(expectedPath); + }); + + it("should navigate to format device path when 'Use the disk without partitions' is clicked for drives", async () => { + const { user } = render(); + const toggleButton = screen.getByRole("button", { name: "Not configured yet" }); + + await user.click(toggleButton); + + const formatDeviceItem = screen.getByText("Use the disk without partitions"); + await user.click(formatDeviceItem); + + const expectedPath = generateEncodedPath(PATHS.formatDevice, { + collection: "drives", + index: 0, + }); + expect(mockNavigateFn).toHaveBeenCalledWith(expectedPath); + }); + + it("should navigate to format device path when 'Use the RAID without partitions' is clicked for mdRaids", async () => { + const { user } = render(); + const toggleButton = screen.getByRole("button", { name: "Not configured yet" }); + + await user.click(toggleButton); + + const formatDeviceItem = screen.getByText("Use the RAID without partitions"); + await user.click(formatDeviceItem); + + const expectedPath = generateEncodedPath(PATHS.formatDevice, { + collection: "mdRaids", + index: 1, + }); + expect(mockNavigateFn).toHaveBeenCalledWith(expectedPath); + }); +}); diff --git a/web/src/components/storage/index.ts b/web/src/components/storage/index.ts index 342fbf03dc..1a83c1c55f 100644 --- a/web/src/components/storage/index.ts +++ b/web/src/components/storage/index.ts @@ -21,7 +21,6 @@ */ export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; -export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; export { default as ProposalResultSection } from "./ProposalResultSection"; export { default as DevicesFormSelect } from "./DevicesFormSelect"; export { default as SpaceActionsTable } from "./SpaceActionsTable"; diff --git a/web/src/components/storage/iscsi/InitiatorSection.test.tsx b/web/src/components/storage/iscsi/InitiatorSection.test.tsx index ec1fbd36ca..8bdcdb26a3 100644 --- a/web/src/components/storage/iscsi/InitiatorSection.test.tsx +++ b/web/src/components/storage/iscsi/InitiatorSection.test.tsx @@ -25,7 +25,7 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import InitiatorSection from "./InitiatorSection"; -import { ISCSIInitiator } from "~/storage"; +import type { ISCSIInitiator } from "~/model/storage/iscsi"; let initiator: ISCSIInitiator; diff --git a/web/src/components/storage/test-data/full-result-example.js b/web/src/components/storage/test-data/full-result-example.js deleted file mode 100644 index 43419f309d..0000000000 --- a/web/src/components/storage/test-data/full-result-example.js +++ /dev/null @@ -1,1298 +0,0 @@ -export const settings = { - bootDevice: "/dev/vdc", - lvm: false, - spacePolicy: "custom", - spaceActions: [ - { - device: "/dev/vdc3", - action: "force_delete", - }, - { - device: "/dev/vdc4", - action: "resize", - }, - { - device: "/dev/vdc1", - action: "force_delete", - }, - ], - systemVGDevices: [], - encryptionPassword: "", - encryptionMethod: "luks2", - volumes: [ - { - mountPath: "/", - fsType: "Btrfs", - minSize: 18790481920, - autoSize: true, - snapshots: true, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext2", "Ext3", "Ext4", "XFS"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: ["/home"], - }, - }, - { - mountPath: "swap", - fsType: "Swap", - minSize: 1610612736, - maxSize: 1610612736, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Swap"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - }, - }, - ], - installationDevices: [ - { - sid: 70, - name: "/dev/vdc", - description: "Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 32212254720, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0"], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 78, - name: "/dev/vdc1", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 79, - name: "/dev/vdc2", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 80, - name: "/dev/vdc3", - description: "XFS Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 20973568, - size: 1073741824, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part3"], - isEFI: false, - filesystem: { - sid: 92, - type: "xfs", - }, - }, - { - sid: 81, - name: "/dev/vdc4", - description: "Linux", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 23070720, - size: 2147483648, - shrinking: { supported: 2147483136 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part4"], - isEFI: false, - }, - ], - unpartitionedSize: 18253611008, - unusedSlots: [ - { - start: 27265024, - size: 18252545536, - }, - ], - }, - }, - ], -}; - -export const devices = { - system: [ - { - sid: 71, - name: "/dev/vda", - description: "Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 53687091200, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0"], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 83, - name: "/dev/vda1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part1"], - isEFI: false, - }, - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - ], - unpartitionedSize: 1065472, - unusedSlots: [], - }, - }, - { - sid: 69, - name: "/dev/vdb", - description: "Ext4 Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:08:00.0"], - filesystem: { - sid: 87, - type: "ext4", - }, - }, - { - sid: 70, - name: "/dev/vdc", - description: "Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 32212254720, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0"], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 78, - name: "/dev/vdc1", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 79, - name: "/dev/vdc2", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 80, - name: "/dev/vdc3", - description: "XFS Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 20973568, - size: 1073741824, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part3"], - isEFI: false, - filesystem: { - sid: 92, - type: "xfs", - }, - }, - { - sid: 81, - name: "/dev/vdc4", - description: "Linux", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 23070720, - size: 2147483648, - shrinking: { supported: 2147483136 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part4"], - isEFI: false, - }, - ], - unpartitionedSize: 18253611008, - unusedSlots: [ - { - start: 27265024, - size: 18252545536, - }, - ], - }, - }, - { - sid: 72, - name: "/dev/md0", - description: "Disk", - isDrive: false, - type: "md", - level: "raid0", - uuid: "644aeee1:5f5b946a:4da99758:3f85b3ea", - devices: [ - { - sid: 78, - name: "/dev/vdc1", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 79, - name: "/dev/vdc2", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - ], - active: true, - encrypted: false, - start: 0, - size: 10737287168, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea"], - udevPaths: [], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 86, - name: "/dev/md0p1", - description: "Ext4 Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 2147483648, - shrinking: { supported: 2040147968 }, - systems: [], - udevIds: ["md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea-part1"], - udevPaths: [], - isEFI: false, - filesystem: { - sid: 93, - type: "ext4", - }, - }, - ], - unpartitionedSize: 8589803520, - unusedSlots: [ - { - start: 4196352, - size: 8588738048, - }, - ], - }, - }, - { - sid: 73, - name: "/dev/system", - description: "LVM", - isDrive: false, - type: "lvmVg", - size: 53674508288, - physicalVolumes: [ - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - ], - logicalVolumes: [ - { - sid: 75, - name: "/dev/system/root", - description: "Ext4 LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 51527024640, - shrinking: { supported: 30647779328 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 88, - type: "ext4", - mountPath: "/", - }, - }, - { - sid: 76, - name: "/dev/system/swap", - description: "Swap LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 2147483648, - shrinking: { supported: 2143289344 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 90, - type: "swap", - mountPath: "swap", - }, - }, - ], - }, - { - sid: 75, - name: "/dev/system/root", - description: "Ext4 LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 51527024640, - shrinking: { supported: 30647779328 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 88, - type: "ext4", - mountPath: "/", - }, - }, - { - sid: 76, - name: "/dev/system/swap", - description: "Swap LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 2147483648, - shrinking: { supported: 2143289344 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 90, - type: "swap", - mountPath: "swap", - }, - }, - { - sid: 83, - name: "/dev/vda1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part1"], - isEFI: false, - }, - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - { - sid: 78, - name: "/dev/vdc1", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 79, - name: "/dev/vdc2", - description: "Part of md0", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - }, - }, - { - sid: 80, - name: "/dev/vdc3", - description: "XFS Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 20973568, - size: 1073741824, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part3"], - isEFI: false, - filesystem: { - sid: 92, - type: "xfs", - }, - }, - { - sid: 81, - name: "/dev/vdc4", - description: "Linux", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 23070720, - size: 2147483648, - shrinking: { supported: 2147483136 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part4"], - isEFI: false, - }, - { - sid: 86, - name: "/dev/md0p1", - description: "Ext4 Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 2147483648, - shrinking: { supported: 2040147968 }, - systems: [], - udevIds: ["md-uuid-644aeee1:5f5b946a:4da99758:3f85b3ea-part1"], - udevPaths: [], - isEFI: false, - filesystem: { - sid: 93, - type: "ext4", - }, - }, - ], - staging: [ - { - sid: 71, - name: "/dev/vda", - description: "Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 53687091200, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0"], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 83, - name: "/dev/vda1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part1"], - isEFI: false, - }, - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - ], - unpartitionedSize: 1065472, - unusedSlots: [], - }, - }, - { - sid: 69, - name: "/dev/vdb", - description: "Ext4 Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 5368709120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:08:00.0"], - filesystem: { - sid: 87, - type: "ext4", - }, - }, - { - sid: 70, - name: "/dev/vdc", - description: "Disk", - isDrive: true, - type: "disk", - vendor: "", - model: "Disk", - driver: ["virtio-pci", "virtio_blk"], - bus: "None", - busId: "", - transport: "unknown", - sdCard: false, - dellBOSS: false, - active: true, - encrypted: false, - start: 0, - size: 32212254720, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0"], - partitionTable: { - type: "gpt", - partitions: [ - { - sid: 79, - name: "/dev/vdc2", - description: "Linux RAID", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { supported: 5368708608 }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - }, - { - sid: 81, - name: "/dev/vdc4", - description: "Linux", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 23070720, - size: 1608515584, - shrinking: { supported: 1608515072 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part4"], - isEFI: false, - }, - { - sid: 459, - name: "/dev/vdc1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - }, - { - sid: 460, - name: "/dev/vdc3", - description: "Swap Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 1610612736, - shrinking: { supported: 1610571776 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part3"], - isEFI: false, - filesystem: { - sid: 461, - type: "swap", - mountPath: "swap", - }, - }, - { - sid: 463, - name: "/dev/vdc5", - description: "Btrfs Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 26212352, - size: 18791513600, - shrinking: { supported: 18523078144 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part5"], - isEFI: false, - filesystem: { - sid: 464, - type: "btrfs", - mountPath: "/", - }, - }, - ], - unpartitionedSize: 4824515072, - unusedSlots: [ - { - start: 3164160, - size: 3749707776, - }, - { - start: 20973568, - size: 1073741824, - }, - ], - }, - }, - { - sid: 73, - name: "/dev/system", - description: "LVM", - isDrive: false, - type: "lvmVg", - size: 53674508288, - physicalVolumes: [ - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - ], - logicalVolumes: [ - { - sid: 75, - name: "/dev/system/root", - description: "Ext4 LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 51527024640, - shrinking: { supported: 30647779328 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 88, - type: "ext4", - mountPath: "/", - }, - }, - { - sid: 76, - name: "/dev/system/swap", - description: "Swap LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 2147483648, - shrinking: { supported: 2143289344 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 90, - type: "swap", - mountPath: "swap", - }, - }, - ], - }, - { - sid: 75, - name: "/dev/system/root", - description: "Ext4 LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 51527024640, - shrinking: { supported: 30647779328 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 88, - type: "ext4", - mountPath: "/", - }, - }, - { - sid: 76, - name: "/dev/system/swap", - description: "Swap LV", - isDrive: false, - type: "lvmLv", - active: true, - encrypted: false, - start: 0, - size: 2147483648, - shrinking: { supported: 2143289344 }, - systems: [], - udevIds: [], - udevPaths: [], - filesystem: { - sid: 90, - type: "swap", - mountPath: "swap", - }, - }, - { - sid: 83, - name: "/dev/vda1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part1"], - isEFI: false, - }, - { - sid: 84, - name: "/dev/vda2", - description: "PV of system", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 53677637120, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:04:00.0-part2"], - isEFI: false, - component: { - type: "physical_volume", - deviceNames: ["/dev/system"], - }, - }, - { - sid: 79, - name: "/dev/vdc2", - description: "Linux RAID", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 10487808, - size: 5368709120, - shrinking: { supported: 5368708608 }, - systems: ["openSUSE Leap 15.2", "Fedora 10.30"], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part2"], - isEFI: false, - }, - { - sid: 81, - name: "/dev/vdc4", - description: "Linux", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 23070720, - size: 1608515584, - shrinking: { supported: 1608515072 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part4"], - isEFI: false, - }, - { - sid: 459, - name: "/dev/vdc1", - description: "BIOS Boot Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 2048, - size: 8388608, - shrinking: { supported: 8388096 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part1"], - isEFI: false, - }, - { - sid: 460, - name: "/dev/vdc3", - description: "Swap Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 18432, - size: 1610612736, - shrinking: { supported: 1610571776 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part3"], - isEFI: false, - filesystem: { - sid: 461, - type: "swap", - mountPath: "swap", - }, - }, - { - sid: 463, - name: "/dev/vdc5", - description: "Btrfs Partition", - isDrive: false, - type: "partition", - active: true, - encrypted: false, - start: 26212352, - size: 18791513600, - shrinking: { supported: 18523078144 }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:09:00.0-part5"], - isEFI: false, - filesystem: { - sid: 464, - type: "btrfs", - mountPath: "/", - }, - }, - ], -}; - -export const actions = [ - { - device: 86, - text: "Delete partition /dev/md0p1 (2.00 GiB)", - subvol: false, - delete: true, - resize: false, - }, - { - device: 72, - text: "Delete RAID0 /dev/md0 (10.00 GiB)", - subvol: false, - delete: true, - resize: false, - }, - { - device: 80, - text: "Delete partition /dev/vdc3 (1.00 GiB)", - subvol: false, - delete: true, - resize: false, - }, - { - device: 78, - text: "Delete partition /dev/vdc1 (5.00 GiB)", - subvol: false, - delete: true, - resize: false, - }, - { - device: 81, - text: "Shrink partition /dev/vdc4 from 2.00 GiB to 1.50 GiB", - subvol: false, - delete: false, - resize: true, - }, - { - device: 459, - text: "Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition", - subvol: false, - delete: false, - resize: false, - }, - { - device: 460, - text: "Create partition /dev/vdc3 (1.50 GiB) for swap", - subvol: false, - delete: false, - resize: false, - }, - { - device: 463, - text: "Create partition /dev/vdc5 (17.50 GiB) for / with btrfs", - subvol: false, - delete: false, - resize: false, - }, - { - device: 467, - text: "Create subvolume @ on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 482, - text: "Create subvolume @/boot/grub2/x86_64-efi on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 480, - text: "Create subvolume @/boot/grub2/i386-pc on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 478, - text: "Create subvolume @/var on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 476, - text: "Create subvolume @/usr/local on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 474, - text: "Create subvolume @/srv on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 472, - text: "Create subvolume @/root on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 470, - text: "Create subvolume @/opt on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, - { - device: 468, - text: "Create subvolume @/home on /dev/vdc5 (17.50 GiB)", - subvol: true, - delete: false, - resize: false, - }, -]; diff --git a/web/src/mocks/api.ts b/web/src/mocks/api.ts deleted file mode 100644 index 30e0439e3c..0000000000 --- a/web/src/mocks/api.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) [2025] 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. - */ - -/** - * Mocking HTTP API calls. - */ - -import * as apiStorage from "~/model/storage"; -import * as apiIssues from "~/api/issues"; -import { Device } from "~/api/storage/types/openapi"; - -export type ApiData = { - "/api/storage/devices/available_drives"?: Awaited< - ReturnType - >; - "/api/storage/devices/available_md_raids"?: Awaited< - ReturnType - >; - "/api/storage/devices/system"?: Device[]; - "/api/storage/config_model"?: Awaited>; - "/api/storage/issues"?: Awaited>; -}; - -/** - * Mocked data. - */ -const mockApiData = jest.fn().mockReturnValue({}); - -/** - * Allows mocking data from the HTTP API. - * - * @example - * mockApi({ - * "/api/storage/available_devices": [50, 64] - * }) - */ -const mockApi = (data: ApiData) => mockApiData.mockReturnValue(data); - -const addMockApi = (data: ApiData) => mockApi({ ...mockApiData(), ...data }); - -// Mock get calls. -jest.mock("~/api/http", () => ({ - ...jest.requireActual("~/api/http"), - get: (url: string) => { - const data = mockApiData()[url]; - return Promise.resolve(data); - }, -})); - -export { mockApi, addMockApi }; diff --git a/web/src/model/config.ts b/web/src/model/config.ts index 1951665b0d..31aba6d308 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -30,7 +30,7 @@ type Config = { network?: Network.Config; product?: Product; storage?: Storage.Config; - software?: Software.Config + software?: Software.Config; }; type Product = { diff --git a/web/src/model/config/software.ts b/web/src/model/config/software.ts index 62d68ae4aa..2f6b23064e 100644 --- a/web/src/model/config/software.ts +++ b/web/src/model/config/software.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, diff --git a/web/src/model/status.ts b/web/src/model/status.ts index 3bac8dfcd2..26e0baa432 100644 --- a/web/src/model/status.ts +++ b/web/src/model/status.ts @@ -37,7 +37,15 @@ const fetchInstallerStatus = async (): Promise => { export { fetchInstallerStatus }; type Stage = "installing" | "configuring" | "finished"; -type Scope = "manager" | "l10n" | "product" | "software" | "storage" | "iscsci" | "users"; +type Scope = + | "manager" + | "l10n" + | "product" + | "software" + | "storage" + | "network" + | "iscsci" + | "users"; type Progress = { index: number; scope: Scope; diff --git a/web/src/model/storage/config-model/partitionable.ts b/web/src/model/storage/config-model/partitionable.ts index 868e8a6132..6d34825371 100644 --- a/web/src/model/storage/config-model/partitionable.ts +++ b/web/src/model/storage/config-model/partitionable.ts @@ -68,7 +68,7 @@ function findLocation(config: ConfigModel.Config, name: string): Location | null } function findPartition(device: Device, mountPath: string): ConfigModel.Partition | undefined { - return device.partitions.find((p) => p.mountPath === mountPath); + return device.partitions?.find((p) => p.mountPath === mountPath); } function filterVolumeGroups(config: ConfigModel.Config, device: Device): ConfigModel.VolumeGroup[] { @@ -105,11 +105,11 @@ function isUsed(config: ConfigModel.Config, deviceName: string): boolean { } function isAddingPartitions(device: Device): boolean { - return device.partitions.some((p) => p.mountPath && configModel.partition.isNew(p)); + return device.partitions?.some((p) => p.mountPath && configModel.partition.isNew(p)) || false; } function isReusingPartitions(device: Device): boolean { - return device.partitions.some(configModel.partition.isReused); + return device.partitions?.some(configModel.partition.isReused) || false; } function remove( diff --git a/web/src/model/system/software.ts b/web/src/model/system/software.ts index 5ef330a509..c0cd8dd10a 100644 --- a/web/src/model/system/software.ts +++ b/web/src/model/system/software.ts @@ -40,7 +40,7 @@ type Pattern = { /** Icon name (not path or file name!) */ icon: string; /** Whether the pattern is selected by default */ - preselected: boolean + preselected: boolean; }; type Repository = { diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 8605ea914a..79605afcb5 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -108,12 +108,12 @@ jest.mock("react-router", () => ({ /** * Internal mock for manipulating progresses */ -let progressesMock = jest.fn().mockReturnValue([]); +const progressesMock = jest.fn().mockReturnValue([]); /** * Internal mock for manipulating stage */ -let stageMock = jest.fn().mockReturnValue("configuring"); +const stageMock = jest.fn().mockReturnValue("configuring"); /** * Allows mocking useStatus#progresses for testing purpose