diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml
new file mode 100644
index 0000000000..7bd3ab733f
--- /dev/null
+++ b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml
index 82970322e0..9535bb3c0f 100644
--- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml
+++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml
@@ -1,5 +1,6 @@
+
@@ -28,12 +29,18 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -54,8 +61,10 @@
-
-
+
+
+
+
@@ -66,8 +75,4 @@
-
-
-
-
diff --git a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml
new file mode 120000
index 0000000000..9cfd11ce93
--- /dev/null
+++ b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml
@@ -0,0 +1 @@
+org.opensuse.Agama.Software1.Product.bus.xml
\ No newline at end of file
diff --git a/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml
new file mode 100644
index 0000000000..165a67094b
--- /dev/null
+++ b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml
new file mode 100644
index 0000000000..bacbe87d94
--- /dev/null
+++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml
index 7c2cc909fd..1a1a3ed0e0 100644
--- a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml
+++ b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml
@@ -1,7 +1,9 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/products.d/ALP-Dolomite.yaml b/products.d/ALP-Dolomite.yaml
index 345c7a2461..c2d4e7d039 100644
--- a/products.d/ALP-Dolomite.yaml
+++ b/products.d/ALP-Dolomite.yaml
@@ -15,16 +15,6 @@ translations:
bezpečnost pro poskytování úplného minima ke spuštění úloh a služeb v
kontejnerech nebo virtuálních strojích.
software:
- installation_repositories:
- - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/
- archs: x86_64
- - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/
- archs: aarch64
- - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/s390x/product/
- archs: s390
- - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/ppc64le/product/
- archs: ppc
-
mandatory_patterns:
- alp_base_zypper
- alp_cockpit
diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs
index e9a9f205ca..7124e00cbb 100644
--- a/rust/agama-lib/src/error.rs
+++ b/rust/agama-lib/src/error.rs
@@ -10,14 +10,14 @@ pub enum ServiceError {
DBus(#[from] zbus::Error),
#[error("Could not connect to Agama bus at '{0}'")]
DBusConnectionError(String, #[source] zbus::Error),
- #[error("Unknown product '{0}'. Available products: '{1:?}'")]
- UnknownProduct(String, Vec),
// it's fine to say only "Error" because the original
// specific error will be printed too
#[error("Error: {0}")]
Anyhow(#[from] anyhow::Error),
#[error("Wrong user parameters: '{0:?}'")]
WrongUser(Vec),
+ #[error("Error: {0}")]
+ UnsuccessfulAction(String),
}
#[derive(Error, Debug)]
diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs
index 1a56faf9da..2d6ae30c39 100644
--- a/rust/agama-lib/src/software/client.rs
+++ b/rust/agama-lib/src/software/client.rs
@@ -1,4 +1,4 @@
-use super::proxies::Software1Proxy;
+use super::proxies::SoftwareProductProxy;
use crate::error::ServiceError;
use serde::Serialize;
use zbus::Connection;
@@ -16,21 +16,21 @@ pub struct Product {
/// D-Bus client for the software service
pub struct SoftwareClient<'a> {
- software_proxy: Software1Proxy<'a>,
+ product_proxy: SoftwareProductProxy<'a>,
}
impl<'a> SoftwareClient<'a> {
pub async fn new(connection: Connection) -> Result, ServiceError> {
Ok(Self {
- software_proxy: Software1Proxy::new(&connection).await?,
+ product_proxy: SoftwareProductProxy::new(&connection).await?,
})
}
/// Returns the available products
pub async fn products(&self) -> Result, ServiceError> {
let products: Vec = self
- .software_proxy
- .available_base_products()
+ .product_proxy
+ .available_products()
.await?
.into_iter()
.map(|(id, name, data)| {
@@ -50,11 +50,22 @@ impl<'a> SoftwareClient<'a> {
/// Returns the selected product to install
pub async fn product(&self) -> Result {
- Ok(self.software_proxy.selected_base_product().await?)
+ Ok(self.product_proxy.selected_product().await?)
}
/// Selects the product to install
pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> {
- Ok(self.software_proxy.select_product(product_id).await?)
+ let result = self.product_proxy.select_product(product_id).await?;
+
+ match result {
+ (0, _) => Ok(()),
+ (3, description) => {
+ let products = self.products().await?;
+ let ids: Vec = products.into_iter().map(|p| p.id).collect();
+ let error = format!("{0}. Available products: '{1:?}'", description, ids);
+ Err(ServiceError::UnsuccessfulAction(error))
+ }
+ (_, description) => Err(ServiceError::UnsuccessfulAction(description)),
+ }
}
}
diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs
index 8803fce495..8fc081b3c6 100644
--- a/rust/agama-lib/src/software/proxies.rs
+++ b/rust/agama-lib/src/software/proxies.rs
@@ -1,6 +1,6 @@
//! D-Bus interface proxies for: `org.opensuse.Agama.Software1.*`
//!
-//! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`.
+//! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data.
use zbus::dbus_proxy;
#[dbus_proxy(
@@ -9,6 +9,9 @@ use zbus::dbus_proxy;
default_path = "/org/opensuse/Agama/Software1"
)]
trait Software1 {
+ /// AddPattern method
+ fn add_pattern(&self, id: &str) -> zbus::Result<()>;
+
/// Finish method
fn finish(&self) -> zbus::Result<()>;
@@ -18,6 +21,12 @@ trait Software1 {
/// IsPackageInstalled method
fn is_package_installed(&self, name: &str) -> zbus::Result;
+ /// ListPatterns method
+ fn list_patterns(
+ &self,
+ filtered: bool,
+ ) -> zbus::Result>;
+
/// Probe method
fn probe(&self) -> zbus::Result<()>;
@@ -27,15 +36,32 @@ trait Software1 {
/// ProvisionsSelected method
fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result>;
- /// SelectProduct method
- fn select_product(&self, product_id: &str) -> zbus::Result<()>;
+ /// RemovePattern method
+ fn remove_pattern(&self, id: &str) -> zbus::Result<()>;
+
+ /// SetUserPatterns method
+ fn set_user_patterns(&self, ids: &[&str]) -> zbus::Result<()>;
/// UsedDiskSpace method
fn used_disk_space(&self) -> zbus::Result;
- /// AvailableBaseProducts property
+ /// SelectedPatterns property
+ #[dbus_proxy(property)]
+ fn selected_patterns(&self) -> zbus::Result>;
+}
+
+#[dbus_proxy(
+ interface = "org.opensuse.Agama.Software1.Product",
+ default_service = "org.opensuse.Agama.Software1",
+ default_path = "/org/opensuse/Agama/Software1/Product"
+)]
+trait SoftwareProduct {
+ /// SelectProduct method
+ fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>;
+
+ /// AvailableProducts property
#[dbus_proxy(property)]
- fn available_base_products(
+ fn available_products(
&self,
) -> zbus::Result<
Vec<(
@@ -45,9 +71,9 @@ trait Software1 {
)>,
>;
- /// SelectedBaseProduct property
+ /// SelectedProduct property
#[dbus_proxy(property)]
- fn selected_base_product(&self) -> zbus::Result;
+ fn selected_product(&self) -> zbus::Result;
}
#[dbus_proxy(
diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs
index 45706ed531..9a8a5fb365 100644
--- a/rust/agama-lib/src/software/store.rs
+++ b/rust/agama-lib/src/software/store.rs
@@ -29,15 +29,10 @@ impl<'a> SoftwareStore<'a> {
pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> {
if let Some(product) = &settings.product {
- let products = self.software_client.products().await?;
- let ids: Vec = products.into_iter().map(|p| p.id).collect();
- if ids.contains(product) {
- self.software_client.select_product(product).await?;
- self.manager_client.probe().await?;
- } else {
- return Err(ServiceError::UnknownProduct(product.clone(), ids));
- }
+ self.software_client.select_product(product).await?;
+ self.manager_client.probe().await?;
}
+
Ok(())
}
}
diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes
index 75e95ad9f3..940cd18117 100644
--- a/rust/package/agama-cli.changes
+++ b/rust/package/agama-cli.changes
@@ -1,4 +1,10 @@
-------------------------------------------------------------------
+Wed Nov 15 12:35:32 UTC 2023 - José Iván López González
+
+- Adapt to changes in software D-Bus API (gh#openSUSE/agama#869).
+
+-------------------------------------------------------------------
+
Wed Nov 15 11:27:10 UTC 2023 - Michal Filka
- Improved "agama logs store" (gh#openSUSE/agama#823)
@@ -14,7 +20,7 @@ Mon Oct 23 14:43:59 UTC 2023 - Michal Filka
- Improved "agama logs store" (gh#openSUSE/agama#812)
- the archive file owner is root:root
- - the permissions is set to r/w for the owner
+ - the permissions is set to r/w for the owner
-------------------------------------------------------------------
Mon Oct 23 11:33:40 UTC 2023 - Imobach Gonzalez Sosa
@@ -58,7 +64,7 @@ Tue Sep 19 11:16:16 UTC 2023 - José Iván López González
-------------------------------------------------------------------
Thu Sep 14 19:44:57 UTC 2023 - Josef Reidinger
-- Improve questions CLI help text (gh#openSUSE/agama#754)
+- Improve questions CLI help text (gh#openSUSE/agama#754)
-------------------------------------------------------------------
Thu Sep 14 10:10:37 UTC 2023 - Imobach Gonzalez Sosa
diff --git a/service/Gemfile.lock b/service/Gemfile.lock
index c114df5bb3..89fdf2541e 100644
--- a/service/Gemfile.lock
+++ b/service/Gemfile.lock
@@ -9,7 +9,7 @@ PATH
fast_gettext (~> 2.2.0)
nokogiri (~> 1.13.1)
rexml (~> 3.2.5)
- ruby-dbus (>= 0.23.0.beta2, < 1.0)
+ ruby-dbus (>= 0.23.1, < 1.0)
GEM
remote: https://rubygems.org/
@@ -49,7 +49,7 @@ GEM
rspec-support (~> 3.11.0)
rspec-support (3.11.0)
ruby-augeas (0.5.0)
- ruby-dbus (0.23.0.beta2)
+ ruby-dbus (0.23.1)
rexml
simplecov (0.21.2)
docile (~> 1.1)
diff --git a/service/agama.gemspec b/service/agama.gemspec
index 4f254b40b7..4cf49d25ca 100644
--- a/service/agama.gemspec
+++ b/service/agama.gemspec
@@ -58,5 +58,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "fast_gettext", "~> 2.2.0"
spec.add_dependency "nokogiri", "~> 1.13.1"
spec.add_dependency "rexml", "~> 3.2.5"
- spec.add_dependency "ruby-dbus", ">= 0.23.0.beta2", "< 1.0"
+ spec.add_dependency "ruby-dbus", ">= 0.23.1", "< 1.0"
end
diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb
index 698bf1bfd4..0e524d648c 100644
--- a/service/lib/agama/config.rb
+++ b/service/lib/agama/config.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -139,6 +139,51 @@ def merge(config)
Config.new(simple_merge(data, config.data))
end
+ # Elements that match the current arch.
+ #
+ # @example
+ # config.products #=>
+ # {
+ # "ALP-Dolomite" => {
+ # "software" => {
+ # "installation_repositories" => [
+ # {
+ # "url" => "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/",
+ # "archs" => "x86_64"
+ # },
+ # {
+ # "url" => https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/",
+ # "archs" => "aarch64"
+ # },
+ # "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/"
+ # ]
+ # }
+ # }
+ # }
+ #
+ # Yast::Arch.rpm_arch #=> "x86_64"
+ # config.arch_elements_from("ALP-Dolomite", "software", "installation_repositories",
+ # property: :url) #=> ["https://.../SUSE/Products/ALP-Dolomite/1.0/x86_64/product/",
+ # #=> "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/"]
+ #
+ # @param keys [Array] Config data keys of the collection.
+ # @param property [Symbol|String|nil] Property to retrieve of the elements.
+ #
+ # @return [Array]
+ def arch_elements_from(*keys, property: nil)
+ keys.map!(&:to_s)
+ elements = products.dig(*keys)
+ return [] unless elements.is_a?(Array)
+
+ elements.map do |element|
+ if !element.is_a?(Hash)
+ element
+ elsif arch_match?(element["archs"])
+ property ? element[property.to_s] : element
+ end
+ end.compact
+ end
+
private
# Simple deep merge
@@ -157,5 +202,15 @@ def simple_merge(a_hash, another_hash)
end
end
end
+
+ # Whether the current arch matches any of the given archs.
+ #
+ # @param archs [String] E.g., "x86_64,aarch64"
+ # @return [Boolean]
+ def arch_match?(archs)
+ return true if archs.nil?
+
+ Yast2::ArchFilter.from_string(archs).match?
+ end
end
end
diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb
index 8b953d705d..9ff10a4fc6 100644
--- a/service/lib/agama/dbus/clients/software.rb
+++ b/service/lib/agama/dbus/clients/software.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,18 +20,18 @@
# find current contact information at www.suse.com.
require "agama/dbus/clients/base"
-require "agama/dbus/clients/with_service_status"
+require "agama/dbus/clients/with_issues"
require "agama/dbus/clients/with_progress"
-require "agama/dbus/clients/with_validation"
+require "agama/dbus/clients/with_service_status"
module Agama
module DBus
module Clients
# D-Bus client for software configuration
class Software < Base
- include WithServiceStatus
+ include WithIssues
include WithProgress
- include WithValidation
+ include WithServiceStatus
TYPES = [:package, :pattern].freeze
private_constant :TYPES
@@ -42,6 +42,9 @@ def initialize
@dbus_object = service["/org/opensuse/Agama/Software1"]
@dbus_object.introspect
+ @dbus_product = service["/org/opensuse/Agama/Software1/Product"]
+ @dbus_product.introspect
+
@dbus_proposal = service["/org/opensuse/Agama/Software1/Proposal"]
@dbus_proposal.introspect
end
@@ -55,7 +58,7 @@ def service_name
#
# @return [Array>] name and display name of each product
def available_products
- dbus_object["org.opensuse.Agama.Software1"]["AvailableBaseProducts"].map do |l|
+ dbus_product["org.opensuse.Agama.Software1.Product"]["AvailableProducts"].map do |l|
l[0..1]
end
end
@@ -64,7 +67,7 @@ def available_products
#
# @return [String, nil] name of the product
def selected_product
- product = dbus_object["org.opensuse.Agama.Software1"]["SelectedBaseProduct"]
+ product = dbus_product["org.opensuse.Agama.Software1.Product"]["SelectedProduct"]
return nil if product.empty?
product
@@ -74,7 +77,7 @@ def selected_product
#
# @param name [String]
def select_product(name)
- dbus_object.SelectProduct(name)
+ dbus_product.SelectProduct(name)
end
# Starts the probing process
@@ -169,9 +172,9 @@ def remove_resolvables(unique_id, type, resolvables, optional: false)
#
# @param block [Proc] Callback to run when a product is selected
def on_product_selected(&block)
- on_properties_change(dbus_object) do |_, changes, _|
- base_product = changes["SelectedBaseProduct"]
- block.call(base_product) unless base_product.nil?
+ on_properties_change(dbus_product) do |_, changes, _|
+ product = changes["SelectedProduct"]
+ block.call(product) unless product.nil?
end
end
@@ -180,6 +183,9 @@ def on_product_selected(&block)
# @return [::DBus::Object]
attr_reader :dbus_object
+ # @return [::DBus::Object]
+ attr_reader :dbus_product
+
# @return [::DBus::Object]
attr_reader :dbus_proposal
end
diff --git a/service/lib/agama/dbus/clients/with_issues.rb b/service/lib/agama/dbus/clients/with_issues.rb
index a4a1dc363e..cc7ba3ea4e 100644
--- a/service/lib/agama/dbus/clients/with_issues.rb
+++ b/service/lib/agama/dbus/clients/with_issues.rb
@@ -28,10 +28,25 @@ module WithIssues
ISSUES_IFACE = "org.opensuse.Agama1.Issues"
private_constant :ISSUES_IFACE
- # Returns the issues
+ # Returns issues from all objects that implement the issues interface.
#
# @return [Array]
def issues
+ objects_with_issues_interface.map { |o| issues_from(o) }.flatten
+ end
+
+ # Determines whether there are errors
+ #
+ # @return [Boolean]
+ def errors?
+ issues.any?(&:error?)
+ end
+
+ def objects_with_issues_interface
+ service.root.descendant_objects.select { |o| o.interfaces.include?(ISSUES_IFACE) }
+ end
+
+ def issues_from(dbus_object)
sources = [nil, Issue::Source::SYSTEM, Issue::Source::CONFIG]
severities = [Issue::Severity::WARN, Issue::Severity::ERROR]
@@ -42,13 +57,6 @@ def issues
severity: severities[dbus_issue[3]])
end
end
-
- # Determines whether there are errors
- #
- # @return [Boolean]
- def errors?
- issues.any?(&:error?)
- end
end
end
end
diff --git a/service/lib/agama/dbus/software.rb b/service/lib/agama/dbus/software.rb
index 670cc4517e..ace66f64fc 100644
--- a/service/lib/agama/dbus/software.rb
+++ b/service/lib/agama/dbus/software.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -28,4 +28,5 @@ module Software
end
require "agama/dbus/software/manager"
+require "agama/dbus/software/product"
require "agama/dbus/software/proposal"
diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb
index 25987eea04..aad196e3cb 100644
--- a/service/lib/agama/dbus/software/manager.rb
+++ b/service/lib/agama/dbus/software/manager.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -21,12 +21,12 @@
require "dbus"
require "agama/dbus/base_object"
-require "agama/dbus/with_service_status"
require "agama/dbus/clients/locale"
require "agama/dbus/clients/network"
+require "agama/dbus/interfaces/issues"
require "agama/dbus/interfaces/progress"
require "agama/dbus/interfaces/service_status"
-require "agama/dbus/interfaces/validation"
+require "agama/dbus/with_service_status"
module Agama
module DBus
@@ -36,7 +36,7 @@ class Manager < BaseObject
include WithServiceStatus
include Interfaces::Progress
include Interfaces::ServiceStatus
- include Interfaces::Validation
+ include Interfaces::Issues
PATH = "/org/opensuse/Agama/Software1"
private_constant :PATH
@@ -54,35 +54,17 @@ def initialize(backend, logger)
@selected_patterns = {}
end
+ # List of software related issues, see {DBus::Interfaces::Issues}
+ #
+ # @return [Array]
+ def issues
+ backend.issues
+ end
+
SOFTWARE_INTERFACE = "org.opensuse.Agama.Software1"
private_constant :SOFTWARE_INTERFACE
dbus_interface SOFTWARE_INTERFACE do
- dbus_reader :available_base_products, "a(ssa{sv})"
-
- dbus_reader :selected_base_product, "s"
-
- # documented way to be able to write to patterns and trigger signal
- attr_writer :selected_patterns
-
- # selected patterns is hash with pattern name as id and 0 for user selected and
- # 1 for auto selected. Can be extended in future e.g. for mandatory patterns
- dbus_attr_reader :selected_patterns, "a{sy}"
-
- dbus_method :SelectProduct, "in ProductID:s" do |product_id|
- old_product_id = backend.product
-
- if old_product_id == product_id
- logger.info "Do not changing the product as it is still the same (#{product_id})"
- return
- end
-
- logger.info "Selecting product #{product_id}"
- select_product(product_id)
- dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedBaseProduct" => product_id }, [])
- update_validation # as different product means different software selection
- end
-
# value of result hash is category, description, icon, summary and order
dbus_method :ListPatterns, "in Filtered:b, out Result:a{s(sssss)}" do |filtered|
[
@@ -101,6 +83,10 @@ def initialize(backend, logger)
]
end
+ # selected patterns is hash with pattern name as id and 0 for user selected and
+ # 1 for auto selected. Can be extended in future e.g. for mandatory patterns
+ dbus_reader_attr_accessor :selected_patterns, "a{sy}"
+
dbus_method(:AddPattern, "in id:s") { |p| backend.add_pattern(p) }
dbus_method(:RemovePattern, "in id:s") { |p| backend.remove_pattern(p) }
dbus_method(:SetUserPatterns, "in ids:as") { |ids| backend.user_patterns = ids }
@@ -121,32 +107,12 @@ def initialize(backend, logger)
dbus_method(:Finish) { finish }
end
- def available_base_products
- backend.products.map do |id, data|
- [id, data["name"], { "description" => localized_description(data) }].freeze
- end
- end
-
- # Returns the selected base product
- #
- # @return [String] Product ID or an empty string if no product is selected
- def selected_base_product
- backend.product || ""
- end
-
- def select_product(product_id)
- backend.select_product(product_id)
- end
-
def probe
busy_while { backend.probe }
-
- update_validation # probe do force proposal
end
def propose
busy_while { backend.propose }
- update_validation
nil # explicit nil as return value
end
@@ -179,31 +145,8 @@ def register_callbacks
backend.on_selected_patterns_change do
self.selected_patterns = compute_patterns
end
- end
- # find translated product description if available
- # @param data [Hash] product configuration from the YAML file
- # @return [String,nil] Translated product description (if available)
- # or the untranslated description, nil if not found
- def localized_description(data)
- translations = data["translations"]&.[]("description")
- lang = ENV["LANG"] || ""
-
- # no translations or language not set, return untranslated value
- return data["description"] if !translations.is_a?(Hash) || lang.empty?
-
- # remove the character encoding if present
- lang = lang.split(".").first
- # full matching (language + country)
- return translations[lang] if translations[lang]
-
- # remove the country part
- lang = lang.split("_").first
- # partial match (just the language)
- return translations[lang] if translations[lang]
-
- # fallback to original untranslated description
- data["description"]
+ backend.on_issues_change { issues_properties_changed }
end
USER_SELECTED_PATTERN = 0
diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb
new file mode 100644
index 0000000000..6c94418a9d
--- /dev/null
+++ b/service/lib/agama/dbus/software/product.rb
@@ -0,0 +1,294 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require "dbus"
+require "suse/connect"
+require "agama/dbus/base_object"
+require "agama/dbus/interfaces/issues"
+require "agama/registration"
+
+module Agama
+ module DBus
+ module Software
+ # D-Bus object to manage product configuration.
+ class Product < BaseObject
+ include Interfaces::Issues
+
+ PATH = "/org/opensuse/Agama/Software1/Product"
+ private_constant :PATH
+
+ # @param backend [Agama::Software::Manager]
+ # @param logger [Logger]
+ def initialize(backend, logger)
+ super(PATH, logger: logger)
+ @backend = backend
+ @logger = logger
+ register_callbacks
+ end
+
+ # List of issues, see {DBus::Interfaces::Issues}.
+ #
+ # @return [Array]
+ def issues
+ backend.product_issues
+ end
+
+ def available_products
+ backend.products.map do |product|
+ [product.id, product.display_name, { "description" => product.localized_description }]
+ end
+ end
+
+ # Returns the selected base product.
+ #
+ # @return [String] Product ID or an empty string if no product is selected
+ def selected_product
+ backend.product&.id || ""
+ end
+
+ # Selects a product.
+ #
+ # @param id [String] Product id.
+ # @return [Array(Integer, String)] Result code and a description.
+ # Possible result codes:
+ # 0: success
+ # 1: already selected
+ # 2: deregister first
+ # 3: unknown product
+ def select_product(id)
+ if backend.product&.id == id
+ [1, "Product is already selected"]
+ elsif backend.registration.reg_code
+ [2, "Current product must be deregistered first"]
+ else
+ backend.select_product(id)
+ [0, ""]
+ end
+ rescue ArgumentError
+ [3, "Unknown product"]
+ end
+
+ PRODUCT_INTERFACE = "org.opensuse.Agama.Software1.Product"
+ private_constant :PRODUCT_INTERFACE
+
+ dbus_interface PRODUCT_INTERFACE do
+ dbus_reader :available_products, "a(ssa{sv})"
+
+ dbus_reader :selected_product, "s"
+
+ dbus_method :SelectProduct, "in id:s, out result:(us)" do |id|
+ logger.info "Selecting product #{id}"
+
+ code, description = select_product(id)
+
+ if code == 0
+ dbus_properties_changed(PRODUCT_INTERFACE, { "SelectedProduct" => id }, [])
+ dbus_properties_changed(REGISTRATION_INTERFACE, { "Requirement" => requirement }, [])
+ # FIXME: Product issues might change after selecting a product. Nevertheless,
+ # #on_issues_change callbacks should be used for emitting issues signals, ensuring
+ # they are emitted every time the backend changes its issues. Currently,
+ # #on_issues_change cannot be used for product issues. Note that Software::Manager
+ # backend takes care of both software and product issues. And it already uses
+ # #on_issues_change callbacks for software related issues.
+ issues_properties_changed
+ end
+
+ [[code, description]]
+ end
+ end
+
+ def reg_code
+ backend.registration.reg_code || ""
+ end
+
+ def email
+ backend.registration.email || ""
+ end
+
+ # Registration requirement.
+ #
+ # @return [Integer] Possible values:
+ # 0: not required
+ # 1: optional
+ # 2: mandatory
+ def requirement
+ case backend.registration.requirement
+ when Agama::Registration::Requirement::MANDATORY
+ 2
+ when Agama::Registration::Requirement::OPTIONAL
+ 1
+ else
+ 0
+ end
+ end
+
+ # Tries to register with the given registration code.
+ #
+ # @note Software is not automatically probed after registering the product. The reason is
+ # to avoid dealing with possible probing issues in the registration D-Bus API. Clients
+ # have to explicitly call to #Probe after registering a product.
+ #
+ # @param reg_code [String]
+ # @param email [String, nil]
+ #
+ # @return [Array(Integer, String)] Result code and a description.
+ # Possible result codes:
+ # 0: success
+ # 1: missing product
+ # 2: already registered
+ # 3: registration not required
+ # 4: network error
+ # 5: timeout error
+ # 6: api error
+ # 7: missing credentials
+ # 8: incorrect credentials
+ # 9: invalid certificate
+ # 10: internal error (e.g., parsing json data)
+ def register(reg_code, email: nil)
+ if !backend.product
+ [1, "Product not selected yet"]
+ elsif backend.registration.reg_code
+ [2, "Product already registered"]
+ elsif backend.registration.requirement == Agama::Registration::Requirement::NOT_REQUIRED
+ [3, "Product does not require registration"]
+ else
+ connect_result(first_error_code: 4) do
+ backend.registration.register(reg_code, email: email)
+ end
+ end
+ end
+
+ # Tries to deregister.
+ #
+ # @note Software is not automatically probed after deregistering the product. The reason is
+ # to avoid dealing with possible probing issues in the deregistration D-Bus API. Clients
+ # have to explicitly call to #Probe after deregistering a product.
+ #
+ # @return [Array(Integer, String)] Result code and a description.
+ # Possible result codes:
+ # 0: success
+ # 1: missing product
+ # 2: not registered yet
+ # 3: network error
+ # 4: timeout error
+ # 5: api error
+ # 6: missing credentials
+ # 7: incorrect credentials
+ # 8: invalid certificate
+ # 9: internal error (e.g., parsing json data)
+ def deregister
+ if !backend.product
+ [1, "Product not selected yet"]
+ elsif !backend.registration.reg_code
+ [2, "Product not registered yet"]
+ else
+ connect_result(first_error_code: 3) do
+ backend.registration.deregister
+ end
+ end
+ end
+
+ REGISTRATION_INTERFACE = "org.opensuse.Agama1.Registration"
+ private_constant :REGISTRATION_INTERFACE
+
+ dbus_interface REGISTRATION_INTERFACE do
+ dbus_reader(:reg_code, "s")
+
+ dbus_reader(:email, "s")
+
+ dbus_reader(:requirement, "u")
+
+ dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args|
+ [register(args[0], email: args[1]["Email"])]
+ end
+
+ dbus_method(:Deregister, "out result:(us)") { [deregister] }
+ end
+
+ private
+
+ # @return [Agama::Software]
+ attr_reader :backend
+
+ # @return [Logger]
+ attr_reader :logger
+
+ # Registers callback to be called
+ def register_callbacks
+ # FIXME: Product issues might change after changing the registration. Nevertheless,
+ # #on_issues_change callbacks should be used for emitting issues signals, ensuring they
+ # are emitted every time the backend changes its issues. Currently, #on_issues_change
+ # cannot be used for product issues. Note that Software::Manager backend takes care of
+ # both software and product issues. And it already uses #on_issues_change callbacks for
+ # software related issues.
+ backend.registration.on_change { issues_properties_changed }
+ backend.registration.on_change { registration_properties_changed }
+ end
+
+ def registration_properties_changed
+ dbus_properties_changed(REGISTRATION_INTERFACE,
+ interfaces_and_properties[REGISTRATION_INTERFACE], [])
+ end
+
+ # Result from calling to SUSE connect.
+ #
+ # @raise [Exception] if an unexpected error is found.
+ #
+ # @return [Array(Integer, String)] List including a result code and a description
+ # (e.g., [1, "Connection to registration server failed (network error)"]).
+ def connect_result(first_error_code: 1, &block)
+ block.call
+ [0, ""]
+ rescue SocketError => e
+ connect_result_from_error(e, first_error_code, "network error")
+ rescue Timeout::Error => e
+ connect_result_from_error(e, first_error_code + 1, "timeout")
+ rescue SUSE::Connect::ApiError => e
+ connect_result_from_error(e, first_error_code + 2)
+ rescue SUSE::Connect::MissingSccCredentialsFile => e
+ connect_result_from_error(e, first_error_code + 3, "missing credentials")
+ rescue SUSE::Connect::MalformedSccCredentialsFile => e
+ connect_result_from_error(e, first_error_code + 4, "incorrect credentials")
+ rescue OpenSSL::SSL::SSLError => e
+ connect_result_from_error(e, first_error_code + 5, "invalid certificate")
+ rescue JSON::ParserError => e
+ connect_result_from_error(e, first_error_code + 6)
+ end
+
+ # Generates a result from a given error.
+ #
+ # @param error [Exception]
+ # @param error_code [Integer]
+ # @param details [String, nil]
+ #
+ # @return [Array(Integer, String)] List including an error code and a description.
+ def connect_result_from_error(error, error_code, details = nil)
+ logger.error("Error connecting to registration server: #{error}")
+
+ description = "Connection to registration server failed"
+ description += " (#{details})" if details
+
+ [error_code, description]
+ end
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/dbus/software/proposal.rb b/service/lib/agama/dbus/software/proposal.rb
index 13cd847d0a..cd0e3587d0 100644
--- a/service/lib/agama/dbus/software/proposal.rb
+++ b/service/lib/agama/dbus/software/proposal.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -46,8 +46,6 @@ class Proposal < ::DBus::Object
# @param logger [Logger]
def initialize(logger)
@logger = logger
- @on_change_callbacks = []
-
super(PATH)
end
@@ -55,7 +53,6 @@ def initialize(logger)
dbus_method :AddResolvables,
"in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt|
Yast::PackagesProposal.AddResolvables(id, TYPES[type], resolvables, optional: opt)
- notify_change!
end
dbus_method :GetResolvables,
@@ -66,28 +63,18 @@ def initialize(logger)
dbus_method :SetResolvables,
"in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt|
Yast::PackagesProposal.SetResolvables(id, TYPES[type], resolvables, optional: opt)
- notify_change!
end
dbus_method :RemoveResolvables,
"in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt|
Yast::PackagesProposal.RemoveResolvables(id, TYPES[type], resolvables, optional: opt)
- notify_change!
end
end
- def on_change(&block)
- @on_change_callbacks << block
- end
-
private
# @return [Logger]
attr_reader :logger
-
- def notify_change!
- @on_change_callbacks.each(&:call)
- end
end
end
end
diff --git a/service/lib/agama/dbus/software_service.rb b/service/lib/agama/dbus/software_service.rb
index 01f573aad8..1ce788a18e 100644
--- a/service/lib/agama/dbus/software_service.rb
+++ b/service/lib/agama/dbus/software_service.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -82,17 +82,11 @@ def service
# @return [Array<::DBus::Object>]
def dbus_objects
@dbus_objects ||= [
- dbus_software_manager,
- Agama::DBus::Software::Proposal.new(logger).tap do |proposal|
- proposal.on_change { dbus_software_manager.update_validation }
- end
+ Agama::DBus::Software::Manager.new(@backend, logger),
+ Agama::DBus::Software::Product.new(@backend, logger),
+ Agama::DBus::Software::Proposal.new(logger)
]
end
-
- # @return [Agama::DBus::Software::Manager]
- def dbus_software_manager
- @dbus_software_manager ||= Agama::DBus::Software::Manager.new(@backend, logger)
- end
end
end
end
diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb
index 4f445aad79..b11b5960cb 100644
--- a/service/lib/agama/manager.rb
+++ b/service/lib/agama/manager.rb
@@ -200,7 +200,7 @@ def on_services_status_change(&block)
#
# @return [Boolean]
def valid?
- [users, software].all?(&:valid?) && !storage.errors?
+ users.valid? && !software.errors? && !storage.errors?
end
# Collects the logs and stores them into an archive
diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb
new file mode 100644
index 0000000000..3466147215
--- /dev/null
+++ b/service/lib/agama/registration.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require "fileutils"
+require "yast"
+require "ostruct"
+require "suse/connect"
+require "y2packager/new_repository_setup"
+
+Yast.import "Arch"
+
+module Agama
+ # Handles everything related to registration of system to SCC, RMT or similar.
+ class Registration
+ # Code used for registering the product.
+ #
+ # @return [String, nil] nil if the product is not registered yet.
+ attr_reader :reg_code
+
+ # Email used for registering the product.
+ #
+ # @return [String, nil]
+ attr_reader :email
+
+ module Requirement
+ NOT_REQUIRED = :not_required
+ OPTIONAL = :optional
+ MANDATORY = :mandatory
+ end
+
+ # @param software_manager [Agama::Software::Manager]
+ # @param logger [Logger]
+ def initialize(software_manager, logger)
+ @software = software_manager
+ @logger = logger
+ end
+
+ # Registers the selected product.
+ #
+ # @raise [
+ # SocketError|Timeout::Error|SUSE::Connect::ApiError|
+ # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile|
+ # OpenSSL::SSL::SSLError|JSON::ParserError
+ # ]
+ #
+ # @param code [String] Registration code.
+ # @param email [String] Email for registering the product.
+ def register(code, email: "")
+ return if product.nil? || reg_code
+
+ connect_params = {
+ token: code,
+ email: email
+ }
+
+ login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro)
+ # write the global credentials
+ # TODO: check if we can do it in memory for libzypp
+ SUSE::Connect::YaST.create_credentials_file(login, password)
+
+ target_product = OpenStruct.new(
+ arch: Yast::Arch.rpm_arch,
+ identifier: product.id,
+ version: product.version || "1.0"
+ )
+ activate_params = {}
+ @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email)
+ # if service require specific credentials file, store it
+ @credentials_file = credentials_from_url(@service.url)
+ if @credentials_file
+ SUSE::Connect::YaST.create_credentials_file(login, password, @credentials_file)
+ end
+ Y2Packager::NewRepositorySetup.instance.add_service(@service.name)
+ @software.add_service(@service)
+
+ @reg_code = code
+ @email = email
+ run_on_change_callbacks
+ end
+
+ # Deregisters the selected product.
+ #
+ # It uses the registration code and email passed to {#register}.
+ #
+ # @raise [
+ # SocketError|Timeout::Error|SUSE::Connect::ApiError|
+ # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile|
+ # OpenSSL::SSL::SSLError|JSON::ParserError
+ # ]
+ def deregister
+ return unless reg_code
+
+ Y2Packager::NewRepositorySetup.instance.services.delete(@service.name)
+ @software.remove_service(@service)
+
+ connect_params = {
+ token: reg_code,
+ email: email
+ }
+ SUSE::Connect::YaST.deactivate_system(connect_params)
+ FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself
+ if @credentials_file
+ FileUtils.rm(credentials_path(@credentials_file))
+ @credentials_file = nil
+ end
+
+ @reg_code = nil
+ @email = nil
+ run_on_change_callbacks
+ end
+
+ # Copies credentials files to the target system.
+ def finish
+ return unless reg_code
+
+ files = [credentials_path(@credentials_file), SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE]
+ files.each do |file|
+ dest = File.join(Yast::Installation.destdir, file)
+ FileUtils.cp(file, dest)
+ end
+ end
+
+ # Indicates whether the registration is optional, mandatory or not required.
+ #
+ # @return [Symbol] See {Requirement}.
+ def requirement
+ return Requirement::NOT_REQUIRED unless product
+ return Requirement::MANDATORY if product.repositories.none?
+
+ Requirement::NOT_REQUIRED
+ end
+
+ # Callbacks to be called when registration changes (e.g., a different product is selected).
+ def on_change(&block)
+ @on_change_callbacks ||= []
+ @on_change_callbacks << block
+ end
+
+ private
+
+ # @return [Agama::Software::Manager]
+ attr_reader :software
+
+ # Currently selected product.
+ #
+ # @return [Agama::Software::Product, nil]
+ def product
+ software.product
+ end
+
+ # Product name expected by SCC.
+ #
+ # @return [String] E.g., "ALP-Dolomite-1-x86_64".
+ def target_distro
+ v = product.version.to_s.split(".").first || "1"
+ "#{product.id}-#{v}-#{Yast::Arch.rpm_arch}"
+ end
+
+ def run_on_change_callbacks
+ @on_change_callbacks&.map(&:call)
+ end
+
+ # taken from https://github.com/yast/yast-registration/blob/master/src/lib/registration/url_helpers.rb#L109
+ def credentials_from_url(url)
+ parsed_url = URI(url)
+ params = URI.decode_www_form(parsed_url.query).to_h
+
+ params["credentials"]
+ rescue StandardError
+ # if something goes wrong try to continue like if there is no credentials param
+ nil
+ end
+
+ def credentials_path(file)
+ File.join(SUSE::Connect::YaST::DEFAULT_CREDENTIALS_DIR, file)
+ end
+ end
+end
diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb
index e5d9f256ed..be47087609 100644
--- a/service/lib/agama/software/manager.rb
+++ b/service/lib/agama/software/manager.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2021] SUSE LLC
+# Copyright (c) [2021-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -19,18 +19,21 @@
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
-require "yast"
require "fileutils"
-require "agama/config"
-require "agama/helpers"
-require "agama/with_progress"
-require "agama/validation_error"
+require "yast"
require "y2packager/product"
require "y2packager/resolvable"
-require "yast2/arch_filter"
+require "agama/config"
+require "agama/helpers"
+require "agama/issue"
+require "agama/registration"
require "agama/software/callbacks"
+require "agama/software/product"
+require "agama/software/product_builder"
require "agama/software/proposal"
require "agama/software/repositories_manager"
+require "agama/with_progress"
+require "agama/with_issues"
Yast.import "Package"
Yast.import "Packages"
@@ -40,14 +43,26 @@
module Agama
module Software
- # This class is responsible for software handling
- class Manager
+ # This class is responsible for software handling.
+ #
+ # FIXME: This class has too many responsibilities:
+ # * Address the software service workflow (probe, propose, install).
+ # * Manages repositories, packages, patterns, services.
+ # * Manages product selection.
+ # * Manages software and product related issues.
+ #
+ # It shoud be splitted in separate and smaller classes.
+ class Manager # rubocop:disable Metrics/ClassLength
include Helpers
+ include WithIssues
include WithProgress
GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*"
private_constant :GPG_KEYS_GLOB
+ # Selected product.
+ #
+ # @return [Agama::Product, nil]
attr_reader :product
DEFAULT_LANGUAGES = ["en_US"].freeze
@@ -58,39 +73,52 @@ class Manager
attr_accessor :languages
- # FIXME: what about defining a Product class?
- # @return [Array>] An array containing the product ID and
- # additional information in a hash
+ # Available products for installation.
+ #
+ # @return [Array]
attr_reader :products
+ # @return [Agama::RepositoriesManager]
attr_reader :repositories
+ # @param config [Agama::Config]
+ # @param logger [Logger]
def initialize(config, logger)
@config = config
@logger = logger
@languages = DEFAULT_LANGUAGES
- @products = @config.products
- if @config.multi_product? || @products.empty?
- @product = nil
- else
- @product = @products.keys.first # use the available product as default
- @config.pick_product(@product)
- end
+ @products = build_products
+ @product = @products.first if @products.size == 1
@repositories = RepositoriesManager.new
- on_progress_change { logger.info progress.to_s }
+ # patterns selected by user
+ @user_patterns = []
@selected_patterns_change_callbacks = []
+ on_progress_change { logger.info(progress.to_s) }
end
- def select_product(name)
- return if name == @product
- raise ArgumentError unless @products[name]
+ # Selects a product with the given id.
+ #
+ # @raise {ArgumentError} If id is unknown.
+ #
+ # @param id [String]
+ # @return [Boolean] true on success.
+ def select_product(id)
+ return false if id == product&.id
+
+ new_product = @products.find { |p| p.id == id }
+
+ raise ArgumentError unless new_product
- @config.pick_product(name)
- @product = name
+ @product = new_product
repositories.delete_all
+ update_issues
+ true
end
def probe
+ # Should an error be raised?
+ return unless product
+
logger.info "Probing software"
# as we use liveDVD with normal like ENV, lets temporary switch to normal to use its repos
@@ -110,6 +138,7 @@ def probe
progress.step("Calculating the software proposal") { propose }
Yast::Stage.Set("initial")
+ update_issues
end
def initialize_target_repos
@@ -119,30 +148,19 @@ def initialize_target_repos
# Updates the software proposal
def propose
- proposal.base_product = selected_base_product
+ # Should an error be raised?
+ return unless product
+
+ proposal.base_product = product.name
proposal.languages = languages
select_resolvables
result = proposal.calculate
+ update_issues
logger.info "Proposal result: #{result.inspect}"
selected_patterns_changed
result
end
- # Returns the errors related to the software proposal
- #
- # * Repositories that could not be probed are reported as errors.
- # * If none of the repositories could be probed, do not report missing
- # patterns and/or packages. Those issues does not make any sense if there
- # are no repositories to install from.
- def validate
- errors = repositories.disabled.map do |repo|
- ValidationError.new("Could not read the repository #{repo.name}")
- end
- return errors if repositories.enabled.empty?
-
- errors + proposal.errors
- end
-
# Installs the packages to the target system
def install
steps = proposal.packages_count
@@ -170,6 +188,7 @@ def finish
Yast::Pkg.SourceSaveAll
Yast::Pkg.TargetFinish
Yast::Pkg.SourceCacheCopyTo(Yast::Installation.destdir)
+ registration.finish
end
progress.step("Restoring original repositories") { restore_original_repos }
end
@@ -266,44 +285,98 @@ def used_disk_space
Yast::String.FormatSizeWithPrecision(proposal.packages_size, 1, true)
end
- private
-
- def proposal
- @proposal ||= Proposal.new
+ def registration
+ @registration ||= Registration.new(self, logger)
end
- # @return [Logger]
- attr_reader :logger
+ # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365
+ # rubocop:disable Metrics/AbcSize
+ def add_service(service)
+ # init repos, so we are sure we operate on "/" and have GPG imported
+ initialize_target_repos
+ # save repositories before refreshing added services (otherwise
+ # pkg-bindings will treat them as removed by the service refresh and
+ # unload them)
+ if !Yast::Pkg.SourceSaveAll
+ # error message
+ @logger.error("Saving repository configuration failed.")
+ end
- def import_gpg_keys
- gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s)
- logger.info "Importing GPG keys: #{gpg_keys}"
- gpg_keys.each do |path|
- Yast::Pkg.ImportGPGKey(path, true)
+ @logger.info "Adding service #{service.name.inspect} (#{service.url})"
+ if !Yast::Pkg.ServiceAdd(service.name, service.url.to_s)
+ raise format("Adding service '%s' failed.", service.name)
+ end
+
+ if !Yast::Pkg.ServiceSet(service.name, "autorefresh" => true)
+ # error message
+ raise format("Updating service '%s' failed.", service.name)
end
+
+ # refresh works only for saved services
+ if !Yast::Pkg.ServiceSave(service.name)
+ # error message
+ raise format("Saving service '%s' failed.", service.name)
+ end
+
+ # Force refreshing due timing issues (bnc#967828)
+ if !Yast::Pkg.ServiceForceRefresh(service.name)
+ # error message
+ raise format("Refreshing service '%s' failed.", service.name)
+ end
+ ensure
+ Yast::Pkg.SourceSaveAll
end
+ # rubocop:enable Metrics/AbcSize
- def arch_select(section)
- collection = @config.data["software"][section] || []
- collection.select { |c| !c.is_a?(Hash) || arch_match?(c["archs"]) }
+ def remove_service(service)
+ if Yast::Pkg.ServiceDelete(service.name) && !Yast::Pkg.SourceSaveAll
+ raise format("Removing service '%s' failed.", service_name)
+ end
+
+ true
end
- def arch_collection_for(section, key)
- arch_select(section).map { |r| r.is_a?(Hash) ? r[key] : r }
+ # Issues associated to the product.
+ #
+ # These issues are not considered as software issues, see {#update_issues}.
+ #
+ # @return [Array]
+ def product_issues
+ issues = []
+ issues << missing_product_issue unless product
+ issues << missing_registration_issue if missing_registration?
+ issues
end
- def selected_base_product
- @config.data["software"]["base_product"]
+ private
+
+ # @return [Agama::Config]
+ attr_reader :config
+
+ # @return [Logger]
+ attr_reader :logger
+
+ # Generates a list of products according to the information of the config file.
+ #
+ # @return [Array]
+ def build_products
+ ProductBuilder.new(config).build
end
- def arch_match?(archs)
- return true if archs.nil?
+ def proposal
+ @proposal ||= Proposal.new
+ end
- Yast2::ArchFilter.from_string(archs).match?
+ def import_gpg_keys
+ gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s)
+ logger.info "Importing GPG keys: #{gpg_keys}"
+ gpg_keys.each do |path|
+ Yast::Pkg.ImportGPGKey(path, true)
+ end
end
def add_base_repos
- arch_collection_for("installation_repositories", "url").map { |url| repositories.add(url) }
+ product.repositories.each { |url| repositories.add(url) }
end
REPOS_BACKUP = "/etc/zypp/repos.d.agama.backup"
@@ -331,25 +404,75 @@ def restore_original_repos
FileUtils.mv(REPOS_BACKUP, REPOS_DIR)
end
- # adds resolvables from yaml config for given product
+ # Adds resolvables for selected product
def select_resolvables
- mandatory_patterns = arch_collection_for("mandatory_patterns", "pattern")
- proposal.set_resolvables("agama", :pattern, mandatory_patterns)
+ proposal.set_resolvables("agama", :pattern, product.mandatory_patterns)
+ proposal.set_resolvables("agama", :pattern, product.optional_patterns, optional: true)
+ proposal.set_resolvables("agama", :package, product.mandatory_packages)
+ proposal.set_resolvables("agama", :package, product.optional_packages, optional: true)
+ end
- optional_patterns = arch_collection_for("optional_patterns", "pattern")
- proposal.set_resolvables("agama", :pattern, optional_patterns,
- optional: true)
+ def selected_patterns_changed
+ @selected_patterns_change_callbacks.each(&:call)
+ end
+
+ # Updates the list of software issues.
+ def update_issues
+ self.issues = current_issues
+ end
+
+ # List of current software issues.
+ #
+ # @return [Array]
+ def current_issues
+ return [] unless product
- mandatory_packages = arch_collection_for("mandatory_packages", "package")
- proposal.set_resolvables("agama", :package, mandatory_packages)
+ issues = repos_issues
- optional_packages = arch_collection_for("optional_packages", "package")
- proposal.set_resolvables("agama", :package, optional_packages,
- optional: true)
+ # If none of the repositories could be probed, then do not report missing patterns and/or
+ # packages. Those issues does not make any sense if there are no repositories to install
+ # from.
+ issues += proposal.issues if repositories.enabled.any?
+ issues
end
- def selected_patterns_changed
- @selected_patterns_change_callbacks.each(&:call)
+ # Issues related to the software proposal.
+ #
+ # Repositories that could not be probed are reported as errors.
+ #
+ # @return [Array]
+ def repos_issues
+ repositories.disabled.map do |repo|
+ Issue.new("Could not read the repository #{repo.name}",
+ source: Issue::Source::SYSTEM,
+ severity: Issue::Severity::ERROR)
+ end
+ end
+
+ # Issue when a product is missing
+ #
+ # @return [Agama::Issue]
+ def missing_product_issue
+ Issue.new("Product not selected yet",
+ source: Issue::Source::CONFIG,
+ severity: Issue::Severity::ERROR)
+ end
+
+ # Issue when a product requires registration but it is not registered yet.
+ #
+ # @return [Agama::Issue]
+ def missing_registration_issue
+ Issue.new("Product must be registered",
+ source: Issue::Source::SYSTEM,
+ severity: Issue::Severity::ERROR)
+ end
+
+ # Whether the registration is missing.
+ #
+ # @return [Boolean]
+ def missing_registration?
+ registration.reg_code.nil? &&
+ registration.requirement == Agama::Registration::Requirement::MANDATORY
end
end
end
diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb
new file mode 100644
index 0000000000..24bb8c0b2b
--- /dev/null
+++ b/service/lib/agama/software/product.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+module Agama
+ module Software
+ # Represents a product that Agama can install.
+ class Product
+ # Product id.
+ #
+ # @return [String]
+ attr_reader :id
+
+ # Name of the product to be display.
+ #
+ # @return [String, nil]
+ attr_accessor :display_name
+
+ # Description of the product.
+ #
+ # @return [String, nil]
+ attr_accessor :description
+
+ # Internal name of the product. This is relevant for registering the product.
+ #
+ # @return [String, nil]
+ attr_accessor :name
+
+ # Version of the product. This is relevant for registering the product.
+ #
+ # @return [String, nil] E.g., "1.0".
+ attr_accessor :version
+
+ # List of repositories.
+ #
+ # @return [Array] Empty if the product requires registration.
+ attr_accessor :repositories
+
+ # Mandatory packages.
+ #
+ # @return [Array]
+ attr_accessor :mandatory_packages
+
+ # Optional packages.
+ #
+ # @return [Array]
+ attr_accessor :optional_packages
+
+ # Mandatory patterns.
+ #
+ # @return [Array]
+ attr_accessor :mandatory_patterns
+
+ # Optional patterns.
+ #
+ # @return [Array]
+ attr_accessor :optional_patterns
+
+ # Product translations.
+ #
+ # @example
+ # product.translations #=>
+ # {
+ # "description" => {
+ # "cs" => "Czech translation",
+ # "es" => "Spanish translation"
+ # }
+ #
+ # @return [Hash>]
+ attr_accessor :translations
+
+ # @param id [string] Product id.
+ def initialize(id)
+ @id = id
+ @repositories = []
+ @mandatory_packages = []
+ @optional_packages = []
+ @mandatory_patterns = []
+ @optional_patterns = []
+ @translations = {}
+ end
+
+ # Localized product description.
+ #
+ # If there is no translation for the current language, then the untranslated description is
+ # used.
+ #
+ # @return [String, nil]
+ def localized_description
+ translations = self.translations["description"]
+ lang = ENV["LANG"]
+
+ # No translations or language not set, return untranslated value.
+ return description unless translations && lang
+
+ # Remove the character encoding if present.
+ lang = lang.split(".").first
+ # Full matching (language + country)
+ return translations[lang] if translations[lang]
+
+ # Remove the country part.
+ lang = lang.split("_").first
+ # Partial match (just the language).
+ return translations[lang] if translations[lang]
+
+ # Fallback to original untranslated description.
+ description
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb
new file mode 100644
index 0000000000..b4ecfeddf8
--- /dev/null
+++ b/service/lib/agama/software/product_builder.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require "agama/software/product"
+
+module Agama
+ module Software
+ # Builds products from the information of a config file.
+ class ProductBuilder
+ # @param config [Agama::Config]
+ def initialize(config)
+ @config = config
+ end
+
+ # Builds the products.
+ #
+ # @return [Array]
+ def build
+ config.products.map do |id, attrs|
+ data = product_data_from_config(id)
+
+ Agama::Software::Product.new(id).tap do |product|
+ product.display_name = attrs["name"]
+ product.description = attrs["description"]
+ product.name = data[:name]
+ product.version = data[:version]
+ product.repositories = data[:repositories]
+ product.mandatory_packages = data[:mandatory_packages]
+ product.optional_packages = data[:optional_packages]
+ product.mandatory_patterns = data[:mandatory_patterns]
+ product.optional_patterns = data[:optional_patterns]
+ product.translations = attrs["translations"] || {}
+ end
+ end
+ end
+
+ private
+
+ # @return [Agama::Config]
+ attr_reader :config
+
+ # Data from config, filtering by arch.
+ #
+ # @param id [String]
+ # @return [Hash]
+ def product_data_from_config(id)
+ {
+ name: config.products.dig(id, "software", "base_product"),
+ version: config.products.dig(id, "software", "version"),
+ repositories: config.arch_elements_from(
+ id, "software", "installation_repositories", property: :url
+ ),
+ mandatory_packages: config.arch_elements_from(
+ id, "software", "mandatory_packages", property: :package
+ ),
+ optional_packages: config.arch_elements_from(
+ id, "software", "optional_packages", property: :package
+ ),
+ mandatory_patterns: config.arch_elements_from(
+ id, "software", "mandatory_patterns", property: :pattern
+ ),
+ optional_patterns: config.arch_elements_from(
+ id, "software", "optional_patterns", property: :pattern
+ )
+ }
+ end
+ end
+ end
+end
diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb
index c4310967c2..383e1509d5 100644
--- a/service/lib/agama/software/proposal.rb
+++ b/service/lib/agama/software/proposal.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,7 +20,8 @@
# find current contact information at www.suse.com.
require "yast"
-require "agama/validation_error"
+require "agama/issue"
+require "agama/with_issues"
Yast.import "Stage"
Yast.import "Installation"
@@ -47,25 +48,22 @@ module Software
# proposal.add_resolvables("agama", :pattern, ["enhanced_base"])
# proposal.languages = ["en_US", "de_DE"]
# proposal.calculate #=> true
- # proposal.errors #=> []
+ # proposal.issues #=> []
class Proposal
+ include WithIssues
+
# @return [String,nil] Base product
attr_accessor :base_product
# @return [Array] List of languages to install
attr_accessor :languages
- # @return [Array] List of errors from the calculated proposal
- attr_reader :errors
-
# Constructor
#
# @param logger [Logger]
def initialize(logger: nil)
@logger = logger || Logger.new($stdout)
- @errors = []
@base_product = nil
- @calculated = false
end
# Adds the given list of resolvables to the proposal
@@ -84,14 +82,12 @@ def set_resolvables(unique_id, type, resolvables, optional: false)
#
# @return [Boolean]
def calculate
- @errors.clear
initialize_target
select_base_product
- proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true)
+ @proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true)
solve_dependencies
- @calculated = true
- @errors = find_errors(proposal)
+ update_issues
valid?
end
@@ -115,7 +111,7 @@ def packages_size
#
# @return [Boolean]
def valid?
- @calculated && @errors.empty?
+ !(proposal.nil? || errors?)
end
private
@@ -123,6 +119,11 @@ def valid?
# @return [Logger]
attr_reader :logger
+ # Proposal result
+ #
+ # @return [Hash, nil] nil if not calculated yet.
+ attr_reader :proposal
+
# Initializes the target, closing the previous one
def initialize_target
Yast::Pkg.TargetFinish # ensure that previous target is closed
@@ -148,21 +149,28 @@ def select_base_product
end
end
- # Returns the errors from the attempt to create a proposal
+ # Updates the issues from the attempt to create a proposal.
#
- # It collects errors from:
+ # It collects issues from:
#
- # * The proposal result
- # * The last solver execution
+ # * The proposal result.
+ # * The last solver execution.
#
- # @param proposal_result [Hash] Proposal result; it might contain a "warning" key with warning
- # messages.
- # @return [Array] List of errors
- def find_errors(proposal_result)
+ # @return [Array]
+ def update_issues
+ self.issues = []
+ return unless proposal
+
msgs = []
- msgs.concat(warning_messages(proposal_result))
+ msgs.concat(warning_messages(proposal))
msgs.concat(solver_messages)
- msgs.map { |m| ValidationError.new(m) }
+ issues = msgs.map do |msg|
+ Issue.new(msg,
+ source: Issue::Source::CONFIG,
+ severity: Issue::Severity::ERROR)
+ end
+
+ self.issues = issues
end
# Runs the solver to satisfy the solve_dependencies
diff --git a/service/lib/agama/with_issues.rb b/service/lib/agama/with_issues.rb
index 950ddce6bb..dd45a4cf68 100644
--- a/service/lib/agama/with_issues.rb
+++ b/service/lib/agama/with_issues.rb
@@ -29,6 +29,13 @@ def issues
@issues || []
end
+ # Whether there are errors
+ #
+ # @return [Boolean]
+ def errors?
+ issues.any?(&:error?)
+ end
+
# Sets the list of current issues
#
# @param issues [Array]
diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml
index 62c7dbb179..f708e08f4d 100644
--- a/service/package/gem2rpm.yml
+++ b/service/package/gem2rpm.yml
@@ -30,6 +30,8 @@
Requires: open-iscsi
Requires: yast2-iscsi-client >= 4.5.7
Requires: yast2-users
+ # required for registration
+ Requires: suseconnect-ruby-bindings
# yast2 with ArchFilter
Requires: yast2 >= 4.5.20
%ifarch s390 s390x
diff --git a/service/package/rubygem-agama.changes b/service/package/rubygem-agama.changes
index 3246d84103..882ca7c2d7 100644
--- a/service/package/rubygem-agama.changes
+++ b/service/package/rubygem-agama.changes
@@ -1,3 +1,8 @@
+-------------------------------------------------------------------
+Wed Nov 15 12:31:10 UTC 2023 - José Iván López González
+
+- Add D-Bus API for registering a product (gh#openSUSE/agama#869).
+
-------------------------------------------------------------------
Thu Nov 2 14:00:01 UTC 2023 - Ancor Gonzalez Sosa
diff --git a/service/test/agama/config_test.rb b/service/test/agama/config_test.rb
index 84ede3e81e..f6e6c4dc58 100644
--- a/service/test/agama/config_test.rb
+++ b/service/test/agama/config_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,7 +20,11 @@
# find current contact information at www.suse.com.
require_relative "../test_helper"
+require "yast"
require "agama/config"
+require "agama/product_reader"
+
+Yast.import "Arch"
describe Agama::Config do
let(:config) { described_class.new("web" => { "ssl" => "SOMETHING" }) }
@@ -128,4 +132,137 @@
end
end
end
+
+ describe "#arch_elements_from" do
+ subject { described_class.new }
+
+ before do
+ allow(Agama::ProductReader).to receive(:new).and_return(reader)
+ end
+
+ let(:reader) { instance_double(Agama::ProductReader, load_products: products) }
+
+ context "when the given set of keys does not match any data" do
+ let(:products) do
+ [
+ {
+ "id" => "Product1",
+ "name" => "Test product 1"
+ }
+ ]
+ end
+
+ it "returns an empty array" do
+ expect(subject.arch_elements_from("Product1", "some", "collection")).to be_empty
+ end
+ end
+
+ context "when the given set of keys does not contain a collection" do
+ let(:products) do
+ [
+ {
+ "id" => "Product1",
+ "name" => "Test product 1"
+ }
+ ]
+ end
+
+ it "returns an empty array" do
+ expect(subject.arch_elements_from("Product1", "name")).to be_empty
+ end
+ end
+
+ context "when the given set of keys contains a collection" do
+ let(:products) do
+ [
+ {
+ "id" => "Product1",
+ "some" => {
+ "collection" => [
+ "element1",
+ {
+ "element" => "element2"
+ },
+ {
+ "element" => "element3",
+ "archs" => "x86_64"
+ },
+ {
+ "element" => "element4",
+ "archs" => "x86_64,aarch64"
+ },
+ {
+ "element" => "element5",
+ "archs" => "ppc64"
+ }
+ ]
+ }
+ }
+ ]
+ end
+
+ before do
+ allow(Yast::Arch).to receive("x86_64").and_return(true)
+ allow(Yast::Arch).to receive("aarch64").and_return(false)
+ allow(Yast::Arch).to receive("ppc64").and_return(false)
+ end
+
+ it "returns all the elements that match the current arch" do
+ elements = subject.arch_elements_from("Product1", "some", "collection")
+
+ expect(elements).to contain_exactly(
+ "element1",
+ { "element" => "element2" },
+ { "element" => "element3", "archs" => "x86_64" },
+ { "element" => "element4", "archs" => "x86_64,aarch64" }
+ )
+ end
+
+ context "and there are no elements matching the current arch" do
+ let(:products) do
+ [
+ {
+ "id" => "Product1",
+ "some" => {
+ "collection" => [
+ {
+ "element" => "element1",
+ "archs" => "aarch64"
+ },
+ {
+ "element" => "element2",
+ "archs" => "ppc64"
+ }
+ ]
+ }
+ }
+ ]
+ end
+
+ it "returns an empty list" do
+ elements = subject.arch_elements_from("Product1", "some", "collection")
+
+ expect(elements).to be_empty
+ end
+ end
+
+ context "and some property is requested" do
+ it "returns the property from all elements that match the current arch" do
+ elements = subject.arch_elements_from(
+ "Product1", "some", "collection", property: :element
+ )
+
+ expect(elements).to contain_exactly("element1", "element2", "element3", "element4")
+ end
+ end
+
+ context "and the requested property does not exit" do
+ it "only return elements that are direct values" do
+ elements = subject.arch_elements_from("Product1", "some", "collection", property: :foo)
+
+ expect(elements).to contain_exactly("element1")
+ end
+ end
+ end
+ end
end
diff --git a/service/test/agama/dbus/clients/software_test.rb b/service/test/agama/dbus/clients/software_test.rb
index c6ab60127c..c379920f5e 100644
--- a/service/test/agama/dbus/clients/software_test.rb
+++ b/service/test/agama/dbus/clients/software_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,6 +20,7 @@
# find current contact information at www.suse.com.
require_relative "../../../test_helper"
+require_relative "with_issues_examples"
require_relative "with_service_status_examples"
require_relative "with_progress_examples"
require "agama/dbus/clients/software"
@@ -33,27 +34,32 @@
allow(bus).to receive(:service).with("org.opensuse.Agama.Software1").and_return(service)
allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1")
.and_return(dbus_object)
- allow(dbus_object).to receive(:introspect)
+ allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Product")
+ .and_return(dbus_product)
+ allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal")
+ .and_return(dbus_proposal)
allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Software1")
.and_return(software_iface)
- allow(dbus_object).to receive(:[]).with("org.freedesktop.DBus.Properties")
+ allow(dbus_product).to receive(:[]).with("org.opensuse.Agama.Software1.Product")
+ .and_return(product_iface)
+ allow(dbus_product).to receive(:[]).with("org.freedesktop.DBus.Properties")
.and_return(properties_iface)
- allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal")
- .and_return(dbus_proposal)
end
let(:bus) { instance_double(Agama::DBus::Bus) }
let(:service) { instance_double(::DBus::ProxyService) }
- let(:dbus_object) { instance_double(::DBus::ProxyObject) }
+ let(:dbus_object) { instance_double(::DBus::ProxyObject, introspect: nil) }
+ let(:dbus_product) { instance_double(::DBus::ProxyObject, introspect: nil) }
let(:dbus_proposal) { instance_double(::DBus::ProxyObject, introspect: nil) }
let(:software_iface) { instance_double(::DBus::ProxyObjectInterface) }
let(:properties_iface) { instance_double(::DBus::ProxyObjectInterface) }
+ let(:product_iface) { instance_double(::DBus::ProxyObjectInterface) }
subject { described_class.new }
describe "#available_products" do
before do
- allow(software_iface).to receive(:[]).with("AvailableBaseProducts").and_return(
+ allow(product_iface).to receive(:[]).with("AvailableProducts").and_return(
[
["Tumbleweed", "openSUSE Tumbleweed", {}],
["Leap15.3", "openSUSE Leap 15.3", {}]
@@ -71,7 +77,7 @@
describe "#selected_product" do
before do
- allow(software_iface).to receive(:[]).with("SelectedBaseProduct").and_return(product)
+ allow(product_iface).to receive(:[]).with("SelectedProduct").and_return(product)
end
context "when there is no selected product" do
@@ -93,17 +99,17 @@
describe "#select_product" do
# Using partial double because methods are dynamically added to the proxy object
- let(:dbus_object) { double(::DBus::ProxyObject) }
+ let(:dbus_product) { double(::DBus::ProxyObject, introspect: nil) }
it "selects the given product" do
- expect(dbus_object).to receive(:SelectProduct).with("Tumbleweed")
+ expect(dbus_product).to receive(:SelectProduct).with("Tumbleweed")
subject.select_product("Tumbleweed")
end
end
describe "#probe" do
- let(:dbus_object) { double(::DBus::ProxyObject, Probe: nil) }
+ let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil, Probe: nil) }
it "calls the D-Bus Probe method" do
expect(dbus_object).to receive(:Probe)
@@ -124,7 +130,7 @@
end
describe "#provisions_selected" do
- let(:dbus_object) { double(::DBus::ProxyObject) }
+ let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil) }
it "returns true/false for every tag given" do
expect(dbus_object).to receive(:ProvisionsSelected)
@@ -135,7 +141,10 @@
end
describe "#package_installed?" do
- let(:dbus_object) { double(::DBus::ProxyObject, IsPackageInstalled: installed?) }
+ let(:dbus_object) do
+ double(::DBus::ProxyObject, introspect: nil, IsPackageInstalled: installed?)
+ end
+
let(:package) { "NetworkManager" }
context "when the package is installed" do
@@ -157,7 +166,7 @@
describe "#on_product_selected" do
before do
- allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test")
+ allow(dbus_product).to receive(:path).and_return("/org/opensuse/Agama/Test")
allow(properties_iface).to receive(:on_signal)
end
@@ -180,6 +189,7 @@
end
end
+ include_examples "issues"
include_examples "service status"
include_examples "progress"
end
diff --git a/service/test/agama/dbus/clients/with_issues_examples.rb b/service/test/agama/dbus/clients/with_issues_examples.rb
index e599db742f..d409970358 100644
--- a/service/test/agama/dbus/clients/with_issues_examples.rb
+++ b/service/test/agama/dbus/clients/with_issues_examples.rb
@@ -24,19 +24,54 @@
shared_examples "issues" do
before do
- allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test")
- allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Issues")
- .and_return(issues_properties)
+ allow(service).to receive(:root).and_return(root_node)
+
+ allow(dbus_object1).to receive(:[]).with("org.opensuse.Agama1.Issues")
+ .and_return(issues_interface1)
+
+ allow(dbus_object3).to receive(:[]).with("org.opensuse.Agama1.Issues")
+ .and_return(issues_interface3)
+
+ allow(issues_interface1).to receive(:[]).with("All").and_return(issues1)
+ allow(issues_interface3).to receive(:[]).with("All").and_return(issues3)
+ end
+
+ let(:root_node) do
+ instance_double(::DBus::Node, descendant_objects: [dbus_object1, dbus_object2, dbus_object3])
+ end
+
+ let(:dbus_object1) do
+ instance_double(::DBus::ProxyObject,
+ interfaces: ["org.opensuse.Agama1.Test", "org.opensuse.Agama1.Issues"])
+ end
+
+ let(:dbus_object2) do
+ instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Test"])
+ end
+
+ let(:dbus_object3) do
+ instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Issues"])
end
- let(:issues_properties) { { "All" => issues } }
+ let(:issues_interface1) { instance_double(::DBus::ProxyObjectInterface) }
+
+ let(:issues_interface3) { instance_double(::DBus::ProxyObjectInterface) }
- let(:issues) { [issue1, issue2] }
- let(:issue1) { ["Issue 1", "Details 1", 1, 0] }
- let(:issue2) { ["Issue 2", "Details 2", 2, 1] }
+ let(:issues1) do
+ [
+ ["Issue 1", "Details 1", 1, 0],
+ ["Issue 2", "Details 2", 2, 1]
+ ]
+ end
+
+ let(:issues3) do
+ [
+ ["Issue 3", "Details 3", 1, 0]
+ ]
+ end
describe "#issues" do
- it "returns the list of issues" do
+ it "returns the list of issues from all objects" do
expect(subject.issues).to all(be_a(Agama::Issue))
expect(subject.issues).to contain_exactly(
@@ -51,6 +86,12 @@
details: "Details 2",
source: Agama::Issue::Source::CONFIG,
severity: Agama::Issue::Severity::ERROR
+ ),
+ an_object_having_attributes(
+ description: "Issue 3",
+ details: "Details 3",
+ source: Agama::Issue::Source::SYSTEM,
+ severity: Agama::Issue::Severity::WARN
)
)
end
@@ -58,15 +99,13 @@
describe "#errors?" do
context "if there is any error" do
- let(:issues) { [issue2] }
-
it "returns true" do
expect(subject.errors?).to eq(true)
end
end
context "if there is no error" do
- let(:issues) { [issue1] }
+ let(:issues1) { [] }
it "returns false" do
expect(subject.errors?).to eq(false)
diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb
index b75490ab43..925218b9d5 100644
--- a/service/test/agama/dbus/software/manager_test.rb
+++ b/service/test/agama/dbus/software/manager_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -20,10 +20,13 @@
# find current contact information at www.suse.com.
require_relative "../../../test_helper"
-require "agama/dbus/software/manager"
+require "agama/config"
+require "agama/dbus/clients/locale"
+require "agama/dbus/clients/network"
+require "agama/dbus/interfaces/issues"
require "agama/dbus/interfaces/progress"
require "agama/dbus/interfaces/service_status"
-require "agama/dbus/interfaces/validation"
+require "agama/dbus/software/manager"
require "agama/software"
describe Agama::DBus::Software::Manager do
@@ -31,7 +34,14 @@
let(:logger) { Logger.new($stdout, level: :warn) }
- let(:backend) { instance_double(Agama::Software::Manager) }
+ let(:backend) { Agama::Software::Manager.new(config, logger) }
+
+ let(:config) { Agama::Config.new(config_data) }
+
+ let(:config_data) do
+ path = File.join(FIXTURES_PATH, "root_dir/etc/agama.yaml")
+ YAML.safe_load(File.read(path))
+ end
let(:progress_interface) { Agama::DBus::Interfaces::Progress::PROGRESS_INTERFACE }
@@ -39,42 +49,44 @@
Agama::DBus::Interfaces::ServiceStatus::SERVICE_STATUS_INTERFACE
end
- let(:validation_interface) { Agama::DBus::Interfaces::Validation::VALIDATION_INTERFACE }
+ let(:issues_interface) { Agama::DBus::Interfaces::Issues::ISSUES_INTERFACE }
before do
- allow_any_instance_of(described_class).to receive(:register_callbacks)
- allow_any_instance_of(described_class).to receive(:register_progress_callbacks)
- allow_any_instance_of(described_class).to receive(:register_service_status_callbacks)
+ allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale_client)
+ allow(Agama::DBus::Clients::Network).to receive(:new).and_return(network_client)
+ allow(backend).to receive(:probe)
+ allow(backend).to receive(:propose)
+ allow(backend).to receive(:install)
+ allow(backend).to receive(:finish)
+ allow(subject).to receive(:dbus_properties_changed)
end
- it "defines Progress D-Bus interface" do
- expect(subject.intfs.keys).to include(progress_interface)
+ let(:locale_client) do
+ instance_double(Agama::DBus::Clients::Locale, on_language_selected: nil)
end
- it "defines ServiceStatus D-Bus interface" do
- expect(subject.intfs.keys).to include(service_status_interface)
+ let(:network_client) do
+ instance_double(Agama::DBus::Clients::Network, on_connection_changed: nil)
+ end
+
+ it "defines Issues D-Bus interface" do
+ expect(subject.intfs.keys).to include(issues_interface)
end
- it "defines Validation D-Bus interface" do
- expect(subject.intfs.keys).to include(validation_interface)
+ it "defines Progress D-Bus interface" do
+ expect(subject.intfs.keys).to include(progress_interface)
end
- it "configures callbacks from Progress interface" do
- expect_any_instance_of(described_class).to receive(:register_progress_callbacks)
- subject
+ it "defines ServiceStatus D-Bus interface" do
+ expect(subject.intfs.keys).to include(service_status_interface)
end
- it "configures callbacks from ServiceStatus interface" do
- expect_any_instance_of(described_class).to receive(:register_service_status_callbacks)
- subject
+ it "emits signal when issues changes" do
+ expect(subject).to receive(:issues_properties_changed)
+ backend.issues = []
end
describe "#probe" do
- before do
- allow(subject).to receive(:update_validation)
- allow(backend).to receive(:probe)
- end
-
it "runs the probing, setting the service as busy meanwhile" do
expect(subject.service_status).to receive(:busy)
expect(backend).to receive(:probe)
@@ -82,20 +94,9 @@
subject.probe
end
-
- it "updates validation" do
- expect(subject).to receive(:update_validation)
-
- subject.probe
- end
end
describe "#propose" do
- before do
- allow(subject).to receive(:update_validation)
- allow(backend).to receive(:propose)
- end
-
it "calculates the proposal, setting the service as busy meanwhile" do
expect(subject.service_status).to receive(:busy)
expect(backend).to receive(:propose)
@@ -103,12 +104,6 @@
subject.propose
end
-
- it "updates validation" do
- expect(subject).to receive(:update_validation)
-
- subject.propose
- end
end
describe "#install" do
@@ -140,68 +135,4 @@
expect(installed).to eq(true)
end
end
-
- describe "#available_base_products" do
- # testing product with translations
- products = {
- "Tumbleweed" => {
- "name" => "openSUSE Tumbleweed",
- "description" => "Original description",
- "translations" => {
- "description" => {
- "cs" => "Czech translation",
- "es" => "Spanish translation"
- }
- }
- }
- }
-
- it "returns product ID and name" do
- expect(backend).to receive(:products).and_return(products)
-
- product = subject.available_base_products.first
- expect(product[0]).to eq("Tumbleweed")
- expect(product[1]).to eq("openSUSE Tumbleweed")
- end
-
- it "returns untranslated description when the language is not set" do
- allow(ENV).to receive(:[]).with("LANG").and_return(nil)
- expect(backend).to receive(:products).and_return(products)
-
- product = subject.available_base_products.first
- expect(product[2]["description"]).to eq("Original description")
- end
-
- it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do
- allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8")
- expect(backend).to receive(:products).and_return(products)
-
- product = subject.available_base_products.first
- expect(product[2]["description"]).to eq("Czech translation")
- end
-
- it "returns Czech translation if locale is \"cs\"" do
- allow(ENV).to receive(:[]).with("LANG").and_return("cs")
- expect(backend).to receive(:products).and_return(products)
-
- product = subject.available_base_products.first
- expect(product[2]["description"]).to eq("Czech translation")
- end
-
- it "return untranslated description when translation is not available" do
- allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8")
-
- # testing product without translations
- untranslated = {
- "Tumbleweed" => {
- "name" => "openSUSE Tumbleweed",
- "description" => "Original description"
- }
- }
- expect(backend).to receive(:products).and_return(untranslated)
-
- product = subject.available_base_products.first
- expect(product[2]["description"]).to eq("Original description")
- end
- end
end
diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb
new file mode 100644
index 0000000000..e25c36e661
--- /dev/null
+++ b/service/test/agama/dbus/software/product_test.rb
@@ -0,0 +1,399 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require_relative "../../../test_helper"
+require "agama/dbus/software/product"
+require "agama/config"
+require "agama/registration"
+require "agama/software/manager"
+require "suse/connect"
+
+describe Agama::DBus::Software::Product do
+ subject { described_class.new(backend, logger) }
+
+ let(:logger) { Logger.new($stdout, level: :warn) }
+
+ let(:backend) { Agama::Software::Manager.new(config, logger) }
+
+ let(:config) { Agama::Config.new }
+
+ before do
+ allow(config).to receive(:products).and_return(products)
+ allow(subject).to receive(:dbus_properties_changed)
+ end
+
+ let(:products) do
+ { "Tumbleweed" => {}, "ALP-Dolomite" => {} }
+ end
+
+ it "defines Product D-Bus interface" do
+ expect(subject.intfs.keys).to include("org.opensuse.Agama.Software1.Product")
+ end
+
+ it "defines Registration D-Bus interface" do
+ expect(subject.intfs.keys).to include("org.opensuse.Agama1.Registration")
+ end
+
+ it "defines Issues D-Bus interface" do
+ expect(subject.intfs.keys).to include("org.opensuse.Agama1.Issues")
+ end
+
+ describe "select_product" do
+ context "if the product is correctly selected" do
+ it "returns result code 0 with empty description" do
+ expect(subject.select_product("Tumbleweed")).to contain_exactly(0, "")
+ end
+ end
+
+ context "if the given product is already selected" do
+ before do
+ subject.select_product("Tumbleweed")
+ end
+
+ it "returns result code 1 and description" do
+ expect(subject.select_product("Tumbleweed")).to contain_exactly(1, /already selected/)
+ end
+ end
+
+ context "if the current product is registered" do
+ before do
+ subject.select_product("Leap16")
+ allow(backend.registration).to receive(:reg_code).and_return("123XX432")
+ end
+
+ it "returns result code 2 and description" do
+ expect(subject.select_product("Tumbleweed")).to contain_exactly(2, /must be deregistered/)
+ end
+ end
+
+ context "if the product is unknown" do
+ it "returns result code 3 and description" do
+ expect(subject.select_product("Unknown")).to contain_exactly(3, /unknown product/i)
+ end
+ end
+ end
+
+ describe "#reg_code" do
+ before do
+ allow(backend.registration).to receive(:reg_code).and_return(reg_code)
+ end
+
+ context "if there is no registered product yet" do
+ let(:reg_code) { nil }
+
+ it "returns an empty string" do
+ expect(subject.reg_code).to eq("")
+ end
+ end
+
+ context "if there is a registered product" do
+ let(:reg_code) { "123XX432" }
+
+ it "returns the registration code" do
+ expect(subject.reg_code).to eq("123XX432")
+ end
+ end
+ end
+
+ describe "#email" do
+ before do
+ allow(backend.registration).to receive(:email).and_return(email)
+ end
+
+ context "if there is no registered email" do
+ let(:email) { nil }
+
+ it "returns an empty string" do
+ expect(subject.email).to eq("")
+ end
+ end
+
+ context "if there is a registered email" do
+ let(:email) { "test@suse.com" }
+
+ it "returns the registered email" do
+ expect(subject.email).to eq("test@suse.com")
+ end
+ end
+ end
+
+ describe "#requirement" do
+ before do
+ allow(backend.registration).to receive(:requirement).and_return(requirement)
+ end
+
+ context "if the registration is not required" do
+ let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED }
+
+ it "returns 0" do
+ expect(subject.requirement).to eq(0)
+ end
+ end
+
+ context "if the registration is optional" do
+ let(:requirement) { Agama::Registration::Requirement::OPTIONAL }
+
+ it "returns 1" do
+ expect(subject.requirement).to eq(1)
+ end
+ end
+
+ context "if the registration is mandatory" do
+ let(:requirement) { Agama::Registration::Requirement::MANDATORY }
+
+ it "returns 2" do
+ expect(subject.requirement).to eq(2)
+ end
+ end
+ end
+
+ describe "#register" do
+ before do
+ allow(backend.registration).to receive(:reg_code).and_return(nil)
+ end
+
+ context "if there is no product selected yet" do
+ it "returns result code 1 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i)
+ end
+ end
+
+ context "if there is a selected product" do
+ before do
+ backend.select_product("Tumbleweed")
+
+ allow(backend.product).to receive(:repositories).and_return(repositories)
+ end
+
+ let(:repositories) { [] }
+
+ context "if the product is already registered" do
+ before do
+ allow(backend.registration).to receive(:reg_code).and_return("123XX432")
+ end
+
+ it "returns result code 2 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i)
+ end
+ end
+
+ context "if the product does not require registration" do
+ let(:repositories) { ["https://repo"] }
+
+ it "returns result code 3 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(3, /not require registration/i)
+ end
+ end
+
+ context "if there is a network error" do
+ before do
+ allow(backend.registration).to receive(:register).and_raise(SocketError)
+ end
+
+ it "returns result code 4 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(4, /network error/)
+ end
+ end
+
+ context "if there is a timeout" do
+ before do
+ allow(backend.registration).to receive(:register).and_raise(Timeout::Error)
+ end
+
+ it "returns result code 5 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(5, /timeout/)
+ end
+ end
+
+ context "if there is an API error" do
+ before do
+ allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "")
+ end
+
+ it "returns result code 6 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(6, /registration server failed/)
+ end
+ end
+
+ context "if there is a missing credials error" do
+ before do
+ allow(backend.registration)
+ .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile)
+ end
+
+ it "returns result code 7 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(7, /missing credentials/)
+ end
+ end
+
+ context "if there is an incorrect credials error" do
+ before do
+ allow(backend.registration)
+ .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile)
+ end
+
+ it "returns result code 8 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(8, /incorrect credentials/)
+ end
+ end
+
+ context "if there is an invalid certificate error" do
+ before do
+ allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError)
+ end
+
+ it "returns result code 9 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(9, /invalid certificate/)
+ end
+ end
+
+ context "if there is an internal error" do
+ before do
+ allow(backend.registration).to receive(:register).and_raise(JSON::ParserError)
+ end
+
+ it "returns result code 10 and description" do
+ expect(subject.register("123XX432")).to contain_exactly(10, /registration server failed/)
+ end
+ end
+
+ context "if the registration is correctly done" do
+ before do
+ allow(backend.registration).to receive(:register)
+ end
+
+ it "returns result code 0 with empty description" do
+ expect(subject.register("123XX432")).to contain_exactly(0, "")
+ end
+ end
+ end
+ end
+
+ describe "#deregister" do
+ before do
+ allow(backend.registration).to receive(:reg_code).and_return("123XX432")
+ end
+
+ context "if there is no product selected yet" do
+ it "returns result code 1 and description" do
+ expect(subject.deregister).to contain_exactly(1, /product not selected/i)
+ end
+ end
+
+ context "if there is a selected product" do
+ before do
+ backend.select_product("Tumbleweed")
+ end
+
+ context "if the product is not registered yet" do
+ before do
+ allow(backend.registration).to receive(:reg_code).and_return(nil)
+ end
+
+ it "returns result code 2 and description" do
+ expect(subject.deregister).to contain_exactly(2, /product not registered/i)
+ end
+ end
+
+ context "if there is a network error" do
+ before do
+ allow(backend.registration).to receive(:deregister).and_raise(SocketError)
+ end
+
+ it "returns result code 3 and description" do
+ expect(subject.deregister).to contain_exactly(3, /network error/)
+ end
+ end
+
+ context "if there is a timeout" do
+ before do
+ allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error)
+ end
+
+ it "returns result code 4 and description" do
+ expect(subject.deregister).to contain_exactly(4, /timeout/)
+ end
+ end
+
+ context "if there is an API error" do
+ before do
+ allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "")
+ end
+
+ it "returns result code 5 and description" do
+ expect(subject.deregister).to contain_exactly(5, /registration server failed/)
+ end
+ end
+
+ context "if there is a missing credials error" do
+ before do
+ allow(backend.registration)
+ .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile)
+ end
+
+ it "returns result code 6 and description" do
+ expect(subject.deregister).to contain_exactly(6, /missing credentials/)
+ end
+ end
+
+ context "if there is an incorrect credials error" do
+ before do
+ allow(backend.registration)
+ .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile)
+ end
+
+ it "returns result code 7 and description" do
+ expect(subject.deregister).to contain_exactly(7, /incorrect credentials/)
+ end
+ end
+
+ context "if there is an invalid certificate error" do
+ before do
+ allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError)
+ end
+
+ it "returns result code 8 and description" do
+ expect(subject.deregister).to contain_exactly(8, /invalid certificate/)
+ end
+ end
+
+ context "if there is an internal error" do
+ before do
+ allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError)
+ end
+
+ it "returns result code 9 and description" do
+ expect(subject.deregister).to contain_exactly(9, /registration server failed/)
+ end
+ end
+
+ context "if the deregistration is correctly done" do
+ before do
+ allow(backend.registration).to receive(:deregister)
+ end
+
+ it "returns result code 0 with empty description" do
+ expect(subject.deregister).to contain_exactly(0, "")
+ end
+ end
+ end
+ end
+end
diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb
index e63d6595f4..9e465df938 100644
--- a/service/test/agama/manager_test.rb
+++ b/service/test/agama/manager_test.rb
@@ -25,6 +25,7 @@
require "agama/config"
require "agama/question"
require "agama/dbus/service_status"
+require "agama/users"
describe Agama::Manager do
subject { described_class.new(config, logger) }
@@ -42,7 +43,7 @@
instance_double(
Agama::DBus::Clients::Software,
probe: nil, install: nil, propose: nil, finish: nil, on_product_selected: nil,
- on_service_status_change: nil, selected_product: product, valid?: true
+ on_service_status_change: nil, selected_product: product, errors?: false
)
end
let(:users) do
@@ -202,7 +203,7 @@
context "when the software configuration is not valid" do
before do
- allow(software).to receive(:valid?).and_return(false)
+ allow(software).to receive(:errors?).and_return(true)
end
it "returns false" do
diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb
new file mode 100644
index 0000000000..a2dd6731b8
--- /dev/null
+++ b/service/test/agama/registration_test.rb
@@ -0,0 +1,375 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require_relative "../test_helper"
+require "agama/config"
+require "agama/registration"
+require "agama/software/manager"
+require "suse/connect"
+require "yast"
+require "y2packager/new_repository_setup"
+
+Yast.import("Arch")
+
+describe Agama::Registration do
+ subject { described_class.new(manager, logger) }
+
+ let(:manager) { instance_double(Agama::Software::Manager) }
+
+ let(:logger) { Logger.new($stdout, level: :warn) }
+
+ before do
+ allow(Yast::Arch).to receive(:rpm_arch).and_return("x86_64")
+
+ allow(manager).to receive(:product).and_return(product)
+ allow(manager).to receive(:add_service)
+ allow(manager).to receive(:remove_service)
+
+ allow(SUSE::Connect::YaST).to receive(:announce_system).and_return(["test-user", "12345"])
+ allow(SUSE::Connect::YaST).to receive(:deactivate_system)
+ allow(SUSE::Connect::YaST).to receive(:create_credentials_file)
+ allow(SUSE::Connect::YaST).to receive(:activate_product).and_return(service)
+ allow(Y2Packager::NewRepositorySetup.instance).to receive(:add_service)
+ end
+
+ let(:service) { OpenStruct.new(name: "test-service", url: nil) }
+
+ describe "#register" do
+ context "if there is no product selected yet" do
+ let(:product) { nil }
+
+ it "does not try to register" do
+ expect(SUSE::Connect::YaST).to_not receive(:announce_system)
+
+ subject.register("11112222", email: "test@test.com")
+ end
+ end
+
+ context "if there is a selected product" do
+ let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } }
+
+ context "and the product is already registered" do
+ before do
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "does not try to register" do
+ expect(SUSE::Connect::YaST).to_not receive(:announce_system)
+
+ subject.register("11112222", email: "test@test.com")
+ end
+ end
+
+ context "and the product is not registered yet" do
+ it "announces the system" do
+ expect(SUSE::Connect::YaST).to receive(:announce_system).with(
+ { token: "11112222", email: "test@test.com" },
+ "test-5-x86_64"
+ )
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "creates credentials file" do
+ expect(SUSE::Connect::YaST).to receive(:create_credentials_file)
+ .with("test-user", "12345")
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "activates the selected product" do
+ expect(SUSE::Connect::YaST).to receive(:activate_product).with(
+ an_object_having_attributes(
+ arch: "x86_64", identifier: "test", version: "5.0"
+ ), {}, "test@test.com"
+ )
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "adds the service to software manager" do
+ expect(Y2Packager::NewRepositorySetup.instance)
+ .to receive(:add_service).with("test-service")
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ context "if the service requires a creadentials file" do
+ let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") }
+
+ before do
+ allow(subject).to receive(:credentials_from_url)
+ .with("https://credentials/file").and_return("credentials")
+ end
+
+ it "creates the credentials file" do
+ expect(SUSE::Connect::YaST).to receive(:create_credentials_file)
+ expect(SUSE::Connect::YaST).to receive(:create_credentials_file)
+ .with("test-user", "12345", "credentials")
+
+ subject.register("11112222", email: "test@test.com")
+ end
+ end
+
+ context "if the service does not require a creadentials file" do
+ let(:service) { OpenStruct.new(name: "test-service", url: nil) }
+
+ it "does not create the credentials file" do
+ expect(SUSE::Connect::YaST).to receive(:create_credentials_file)
+ expect(SUSE::Connect::YaST).to_not receive(:create_credentials_file)
+ .with("test-user", "12345", anything)
+
+ subject.register("11112222", email: "test@test.com")
+ end
+ end
+
+ context "if the product was correctly registered" do
+ before do
+ subject.on_change(&callback)
+ end
+
+ let(:callback) { proc {} }
+
+ it "runs the callbacks" do
+ expect(callback).to receive(:call)
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "sets the registration code" do
+ subject.register("11112222", email: "test@test.com")
+
+ expect(subject.reg_code).to eq("11112222")
+ end
+
+ it "sets the email" do
+ subject.register("11112222", email: "test@test.com")
+
+ expect(subject.email).to eq("test@test.com")
+ end
+ end
+
+ context "if the product was not correctly registered" do
+ before do
+ allow(SUSE::Connect::YaST).to receive(:activate_product).and_raise(Timeout::Error)
+ subject.on_change(&callback)
+ end
+
+ let(:callback) { proc {} }
+
+ it "raises an error" do
+ expect { subject.register("11112222", email: "test@test.com") }
+ .to raise_error(Timeout::Error)
+ end
+
+ it "does not run the callbacks" do
+ expect(callback).to_not receive(:call)
+
+ expect { subject.register("11112222", email: "test@test.com") }
+ .to raise_error(Timeout::Error)
+ end
+
+ it "does not set the registration code" do
+ expect { subject.register("11112222", email: "test@test.com") }
+ .to raise_error(Timeout::Error)
+
+ expect(subject.reg_code).to be_nil
+ end
+
+ it "does not set the email" do
+ expect { subject.register("11112222", email: "test@test.com") }
+ .to raise_error(Timeout::Error)
+
+ expect(subject.email).to be_nil
+ end
+ end
+ end
+ end
+ end
+
+ describe "#deregister" do
+ before do
+ allow(FileUtils).to receive(:rm)
+ end
+
+ context "if there is no product selected yet" do
+ let(:product) { nil }
+
+ it "does not try to deregister" do
+ expect(SUSE::Connect::YaST).to_not receive(:deactivate_system)
+
+ subject.deregister
+ end
+ end
+
+ context "if there is a selected product" do
+ let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } }
+
+ context "and the product is not registered yet" do
+ it "does not try to deregister" do
+ expect(SUSE::Connect::YaST).to_not receive(:deactivate_system)
+
+ subject.deregister
+ end
+ end
+
+ context "and the product is registered" do
+ before do
+ allow(subject).to receive(:credentials_from_url)
+ allow(subject).to receive(:credentials_from_url)
+ .with("https://credentials/file").and_return("credentials")
+
+ subject.register("11112222", email: "test@test.com")
+ end
+
+ it "deletes the service from the software config" do
+ expect(manager).to receive(:remove_service).with(service)
+
+ subject.deregister
+ end
+
+ it "deactivates the system" do
+ expect(SUSE::Connect::YaST).to receive(:deactivate_system).with(
+ { token: "11112222", email: "test@test.com" }
+ )
+
+ subject.deregister
+ end
+
+ it "removes the credentials file" do
+ expect(FileUtils).to receive(:rm).with(/SCCcredentials/)
+
+ subject.deregister
+ end
+
+ context "if the service has a credentials files" do
+ let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") }
+
+ it "removes the credentials file" do
+ expect(FileUtils).to receive(:rm)
+ expect(FileUtils).to receive(:rm).with(/\/credentials$/)
+
+ subject.deregister
+ end
+ end
+
+ context "if the product has no credentials file" do
+ let(:service) { OpenStruct.new(name: "test-service", url: nil) }
+
+ it "does not try to remove the credentials file" do
+ expect(FileUtils).to_not receive(:rm).with(/\/credentials$/)
+
+ subject.deregister
+ end
+ end
+
+ context "if the product was correctly deregistered" do
+ before do
+ subject.on_change(&callback)
+ end
+
+ let(:callback) { proc {} }
+
+ it "runs the callbacks" do
+ expect(callback).to receive(:call)
+
+ subject.deregister
+ end
+
+ it "removes the registration code" do
+ subject.deregister
+
+ expect(subject.reg_code).to be_nil
+ end
+
+ it "removes the email" do
+ subject.deregister
+
+ expect(subject.email).to be_nil
+ end
+ end
+
+ context "if the product was not correctly deregistered" do
+ before do
+ allow(SUSE::Connect::YaST).to receive(:deactivate_system).and_raise(Timeout::Error)
+ subject.on_change(&callback)
+ end
+
+ let(:callback) { proc {} }
+
+ it "raises an error" do
+ expect { subject.deregister }.to raise_error(Timeout::Error)
+ end
+
+ it "does not run the callbacks" do
+ expect(callback).to_not receive(:call)
+
+ expect { subject.deregister }.to raise_error(Timeout::Error)
+ end
+
+ it "does not remove the registration code" do
+ expect { subject.deregister }.to raise_error(Timeout::Error)
+
+ expect(subject.reg_code).to eq("11112222")
+ end
+
+ it "does not remove the email" do
+ expect { subject.deregister }.to raise_error(Timeout::Error)
+
+ expect(subject.email).to eq("test@test.com")
+ end
+ end
+ end
+ end
+ end
+
+ describe "#requirement" do
+ context "if there is not product selected yet" do
+ let(:product) { nil }
+
+ it "returns not required" do
+ expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED)
+ end
+ end
+
+ context "if there is a selected product" do
+ let(:product) do
+ Agama::Software::Product.new("test").tap { |p| p.repositories = repositories }
+ end
+
+ context "and the product has repositories" do
+ let(:repositories) { ["https://repo"] }
+
+ it "returns not required" do
+ expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED)
+ end
+ end
+
+ context "and the product has no repositories" do
+ let(:repositories) { [] }
+
+ it "returns mandatory" do
+ expect(subject.requirement).to eq(Agama::Registration::Requirement::MANDATORY)
+ end
+ end
+ end
+ end
+end
diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb
index a932423199..f899ab64b3 100644
--- a/service/test/agama/software/manager_test.rb
+++ b/service/test/agama/software/manager_test.rb
@@ -20,12 +20,15 @@
# find current contact information at www.suse.com.
require_relative "../../test_helper"
+require_relative "../with_issues_examples"
require_relative "../with_progress_examples"
-require_relative File.join(
- SRC_PATH, "agama", "dbus", "y2dir", "software", "modules", "PackageCallbacks.rb"
-)
+require_relative File.join(SRC_PATH, "agama/dbus/y2dir/software/modules/PackageCallbacks.rb")
require "agama/config"
+require "agama/issue"
+require "agama/registration"
require "agama/software/manager"
+require "agama/software/product"
+require "agama/software/proposal"
require "agama/dbus/clients/questions"
describe Agama::Software::Manager do
@@ -35,15 +38,19 @@
let(:base_url) { "" }
let(:destdir) { "/mnt" }
let(:gpg_keys) { [] }
+
let(:repositories) do
instance_double(
Agama::Software::RepositoriesManager,
add: nil,
load: nil,
delete_all: nil,
- empty?: true
+ empty?: true,
+ enabled: enabled_repos,
+ disabled: disabled_repos
)
end
+
let(:proposal) do
instance_double(
Agama::Software::Proposal,
@@ -51,10 +58,15 @@
calculate: nil,
:languages= => nil,
set_resolvables: nil,
- packages_count: "500 MB"
+ packages_count: "500 MB",
+ issues: proposal_issues
)
end
+ let(:enabled_repos) { [] }
+ let(:disabled_repos) { [] }
+ let(:proposal_issues) { [] }
+
let(:config_path) do
File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml")
end
@@ -84,6 +96,92 @@
allow(Agama::ProductReader).to receive(:new).and_call_original
end
+ describe "#new" do
+ before do
+ allow_any_instance_of(Agama::Software::ProductBuilder)
+ .to receive(:build).and_return(products)
+ end
+
+ context "if there are several products" do
+ let(:products) do
+ [Agama::Software::Product.new("test1"), Agama::Software::Product.new("test2")]
+ end
+
+ it "does not select a product by default" do
+ manager = described_class.new(config, logger)
+
+ expect(manager.product).to be_nil
+ end
+ end
+
+ context "if there is only a product" do
+ let(:products) { [product] }
+
+ let(:product) { Agama::Software::Product.new("test1") }
+
+ it "selects the product" do
+ manager = described_class.new(config, logger)
+
+ expect(manager.product.id).to eq("test1")
+ end
+ end
+ end
+
+ shared_examples "software issues" do |tested_method|
+ before do
+ allow(subject.registration).to receive(:reg_code).and_return(reg_code)
+ end
+
+ let(:reg_code) { "123XX432" }
+ let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] }
+
+ context "if there are disabled repositories" do
+ let(:disabled_repos) do
+ [
+ instance_double(Agama::Software::Repository, name: "Repo #1"),
+ instance_double(Agama::Software::Repository, name: "Repo #2")
+ ]
+ end
+
+ it "adds an issue for each disabled repository" do
+ subject.public_send(tested_method)
+
+ expect(subject.issues).to include(
+ an_object_having_attributes(
+ description: /could not read the repository Repo #1/i
+ ),
+ an_object_having_attributes(
+ description: /could not read the repository Repo #2/i
+ )
+ )
+ end
+ end
+
+ context "if there is any enabled repository" do
+ let(:enabled_repos) { [instance_double(Agama::Software::Repository, name: "Repo #1")] }
+
+ it "adds the proposal issues" do
+ subject.public_send(tested_method)
+
+ expect(subject.issues).to include(an_object_having_attributes(
+ description: /proposal issue/i
+ ))
+ end
+ end
+
+ context "if there is no enabled repository" do
+ let(:enabled_repos) { [] }
+
+ it "does not add the proposal issues" do
+ subject.public_send(tested_method)
+
+ expect(subject.issues).to_not include(an_object_having_attributes(
+ description: /proposal issue/i
+ ))
+ end
+ end
+ end
+
describe "#probe" do
let(:rootdir) { Dir.mktmpdir }
let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") }
@@ -93,6 +191,7 @@
stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir)
stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir)
FileUtils.mkdir_p(repos_dir)
+ subject.select_product("Tumbleweed")
end
after do
@@ -119,20 +218,23 @@
end
it "registers the repository from config" do
- expect(repositories).to receive(:add).with(/Dolomite/)
+ expect(repositories).to receive(:add).with(/tumbleweed/)
expect(repositories).to receive(:load)
subject.probe
end
+
+ include_examples "software issues", "probe"
end
describe "#products" do
it "returns the list of known products" do
products = subject.products
expect(products.size).to eq(3)
- expect(products["Tumbleweed"]).to_not eq nil
- expect(products["Tumbleweed"]).to include(
- "name" => "openSUSE Tumbleweed",
- "description" => String
+ expect(products).to all(be_a(Agama::Software::Product))
+ expect(products).to contain_exactly(
+ an_object_having_attributes(id: "ALP-Dolomite"),
+ an_object_having_attributes(id: "Tumbleweed"),
+ an_object_having_attributes(id: "Leap16")
)
end
end
@@ -140,7 +242,6 @@
describe "#propose" do
before do
subject.select_product("Tumbleweed")
- allow(Yast::Arch).to receive(:s390).and_return(false)
end
it "creates a new proposal for the selected product" do
@@ -150,6 +251,8 @@
subject.propose
end
+ include_examples "software issues", "propose"
+
it "adds the patterns and packages to install depending on the system architecture" do
expect(proposal).to receive(:set_resolvables)
.with("agama", :pattern, ["enhanced_base"])
@@ -190,43 +293,6 @@
end
end
- describe "#validate" do
- before do
- allow(repositories).to receive(:enabled).and_return(enabled_repos)
- allow(repositories).to receive(:disabled).and_return(disabled_repos)
- allow(proposal).to receive(:errors).and_return([proposal_error])
- end
-
- let(:enabled_repos) { [] }
- let(:disabled_repos) { [] }
- let(:proposal_error) { Agama::ValidationError.new("proposal error") }
-
- context "when there are not enabled repositories" do
- it "does not return the proposal errors" do
- expect(subject.validate).to_not include(proposal_error)
- end
- end
-
- context "when there are disabled repositories" do
- let(:disabled_repos) do
- [instance_double(Agama::Software::Repository, name: "Repo #1")]
- end
-
- it "returns an error for each disabled repository" do
- expect(subject.validate.size).to eq(1)
- error = subject.validate.first
- expect(error.message).to match(/Could not read the repository/)
- end
- end
-
- context "when there are enabled repositories" do
- let(:enabled_repos) { [instance_double(Agama::Software::Repository)] }
- it "returns the proposal errors" do
- expect(subject.validate).to include(proposal_error)
- end
- end
- end
-
describe "#finish" do
let(:rootdir) { Dir.mktmpdir }
let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") }
@@ -281,5 +347,83 @@
end
end
+ describe "#product_issues" do
+ before do
+ allow_any_instance_of(Agama::Software::ProductBuilder)
+ .to receive(:build).and_return([product1, product2])
+ end
+
+ let(:product1) do
+ Agama::Software::Product.new("test1").tap { |p| p.repositories = [] }
+ end
+
+ let(:product2) do
+ Agama::Software::Product.new("test2").tap { |p| p.repositories = ["http://test"] }
+ end
+
+ context "if no product is selected yet" do
+ it "contains a missing product issue" do
+ expect(subject.product_issues).to contain_exactly(
+ an_object_having_attributes(
+ description: /product not selected/i
+ )
+ )
+ end
+ end
+
+ context "if a product is already selected" do
+ before do
+ subject.select_product(product_id)
+ end
+
+ let(:product_id) { "test1" }
+
+ it "does not include a missing product issue" do
+ expect(subject.product_issues).to_not include(
+ an_object_having_attributes(
+ description: /product not selected/i
+ )
+ )
+ end
+
+ context "and the product does not require registration" do
+ let(:product_id) { "test2" }
+
+ it "does not contain issues" do
+ expect(subject.product_issues).to be_empty
+ end
+ end
+
+ context "and the product requires registration" do
+ let(:product_id) { "test1" }
+
+ before do
+ allow(subject.registration).to receive(:reg_code).and_return(reg_code)
+ end
+
+ context "and the product is not registered" do
+ let(:reg_code) { nil }
+
+ it "contains a missing registration issue" do
+ expect(subject.product_issues).to contain_exactly(
+ an_object_having_attributes(
+ description: /product must be registered/i
+ )
+ )
+ end
+ end
+
+ context "and the product is registered" do
+ let(:reg_code) { "1234XX5678" }
+
+ it "does not contain issues" do
+ expect(subject.product_issues).to be_empty
+ end
+ end
+ end
+ end
+ end
+
+ include_examples "issues"
include_examples "progress"
end
diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb
new file mode 100644
index 0000000000..a48bc87ceb
--- /dev/null
+++ b/service/test/agama/software/product_builder_test.rb
@@ -0,0 +1,302 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require_relative "../../test_helper"
+require "yast"
+require "agama/config"
+require "agama/product_reader"
+require "agama/software/product"
+require "agama/software/product_builder"
+
+Yast.import "Arch"
+
+describe Agama::Software::ProductBuilder do
+ before do
+ allow(Agama::ProductReader).to receive(:new).and_return(reader)
+ end
+
+ let(:reader) { instance_double(Agama::ProductReader, load_products: products) }
+
+ let(:products) do
+ [
+ {
+ "id" => "Test1",
+ "name" => "Product Test 1",
+ "description" => "This is a test product named Test 1",
+ "translations" => {
+ "description" => {
+ "cs" => "Czech",
+ "es" => "Spanish"
+ }
+ },
+ "software" => {
+ "installation_repositories" => [
+ {
+ "url" => "https://repos/test1/x86_64/product/",
+ "archs" => "x86_64"
+ },
+ {
+ "url" => "https://repos/test1/aarch64/product/",
+ "archs" => "aarch64"
+ }
+ ],
+ "mandatory_packages" => [
+ {
+ "package" => "package1-1"
+ },
+ "package1-2",
+ {
+ "package" => "package1-3",
+ "archs" => "aarch64,x86_64"
+ },
+ {
+ "package" => "package1-4",
+ "archs" => "ppc64"
+ }
+ ],
+ "optional_packages" => ["package1-5"],
+ "mandatory_patterns" => ["pattern1-1", "pattern1-2"],
+ "optional_patterns" => [
+ {
+ "pattern" => "pattern1-3",
+ "archs" => "x86_64"
+ },
+ {
+ "pattern" => "pattern1-4",
+ "archs" => "aarch64"
+ }
+ ],
+ "base_product" => "Test1",
+ "version" => "1.0"
+ }
+ },
+ {
+ "id" => "Test2",
+ "name" => "Product Test 2",
+ "description" => "This is a test product named Test 2",
+ "archs" => "x86_64,aarch64",
+ "software" => {
+ "mandatory_patterns" => ["pattern2-1"],
+ "base_product" => "Test2",
+ "version" => "2.0"
+ }
+ },
+ {
+ "id" => "Test3",
+ "name" => "Product Test 3",
+ "description" => "This is a test product named Test 3",
+ "archs" => "ppc64,aarch64",
+ "software" => {
+ "installation_repositories" => ["https://repos/test3/product/"],
+ "optional_patterns" => [
+ {
+ "pattern" => "pattern3-1",
+ "archs" => "aarch64"
+ }
+ ],
+ "base_product" => "Test3"
+ }
+ }
+ ]
+ end
+
+ subject { described_class.new(config) }
+
+ let(:config) { Agama::Config.new }
+
+ describe "#build" do
+ context "for x86_64" do
+ before do
+ allow(Yast::Arch).to receive("x86_64").and_return(true)
+ allow(Yast::Arch).to receive("aarch64").and_return(false)
+ allow(Yast::Arch).to receive("ppc64").and_return(false)
+ allow(Yast::Arch).to receive("s390").and_return(false)
+ end
+
+ it "generates products according to the current architecture" do
+ products = subject.build
+
+ expect(products).to all(be_a(Agama::Software::Product))
+
+ expect(products).to contain_exactly(
+ an_object_having_attributes(
+ id: "Test1",
+ display_name: "Product Test 1",
+ description: "This is a test product named Test 1",
+ name: "Test1",
+ version: "1.0",
+ repositories: ["https://repos/test1/x86_64/product/"],
+ mandatory_patterns: ["pattern1-1", "pattern1-2"],
+ optional_patterns: ["pattern1-3"],
+ mandatory_packages: ["package1-1", "package1-2", "package1-3"],
+ optional_packages: ["package1-5"],
+ translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } }
+ ),
+ an_object_having_attributes(
+ id: "Test2",
+ display_name: "Product Test 2",
+ description: "This is a test product named Test 2",
+ name: "Test2",
+ version: "2.0",
+ repositories: [],
+ mandatory_patterns: ["pattern2-1"],
+ optional_patterns: [],
+ mandatory_packages: [],
+ optional_packages: [],
+ translations: {}
+ )
+ )
+ end
+ end
+
+ context "for aarch64" do
+ before do
+ allow(Yast::Arch).to receive("x86_64").and_return(false)
+ allow(Yast::Arch).to receive("aarch64").and_return(true)
+ allow(Yast::Arch).to receive("ppc64").and_return(false)
+ allow(Yast::Arch).to receive("s390").and_return(false)
+ end
+
+ it "generates products according to the current architecture" do
+ products = subject.build
+
+ expect(products).to all(be_a(Agama::Software::Product))
+
+ expect(products).to contain_exactly(
+ an_object_having_attributes(
+ id: "Test1",
+ display_name: "Product Test 1",
+ description: "This is a test product named Test 1",
+ name: "Test1",
+ version: "1.0",
+ repositories: ["https://repos/test1/aarch64/product/"],
+ mandatory_patterns: ["pattern1-1", "pattern1-2"],
+ optional_patterns: ["pattern1-4"],
+ mandatory_packages: ["package1-1", "package1-2", "package1-3"],
+ optional_packages: ["package1-5"],
+ translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } }
+ ),
+ an_object_having_attributes(
+ id: "Test2",
+ display_name: "Product Test 2",
+ description: "This is a test product named Test 2",
+ name: "Test2",
+ version: "2.0",
+ repositories: [],
+ mandatory_patterns: ["pattern2-1"],
+ optional_patterns: [],
+ mandatory_packages: [],
+ optional_packages: [],
+ translations: {}
+ ),
+ an_object_having_attributes(
+ id: "Test3",
+ display_name: "Product Test 3",
+ description: "This is a test product named Test 3",
+ name: "Test3",
+ version: nil,
+ repositories: ["https://repos/test3/product/"],
+ mandatory_patterns: [],
+ optional_patterns: ["pattern3-1"],
+ mandatory_packages: [],
+ optional_packages: [],
+ translations: {}
+ )
+ )
+ end
+ end
+
+ context "for ppc64" do
+ before do
+ allow(Yast::Arch).to receive("x86_64").and_return(false)
+ allow(Yast::Arch).to receive("aarch64").and_return(false)
+ allow(Yast::Arch).to receive("ppc64").and_return(true)
+ allow(Yast::Arch).to receive("s390").and_return(false)
+ end
+
+ it "generates products according to the current architecture" do
+ products = subject.build
+
+ expect(products).to all(be_a(Agama::Software::Product))
+
+ expect(products).to contain_exactly(
+ an_object_having_attributes(
+ id: "Test1",
+ display_name: "Product Test 1",
+ description: "This is a test product named Test 1",
+ name: "Test1",
+ version: "1.0",
+ repositories: [],
+ mandatory_patterns: ["pattern1-1", "pattern1-2"],
+ optional_patterns: [],
+ mandatory_packages: ["package1-1", "package1-2", "package1-4"],
+ optional_packages: ["package1-5"],
+ translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } }
+ ),
+ an_object_having_attributes(
+ id: "Test3",
+ display_name: "Product Test 3",
+ description: "This is a test product named Test 3",
+ name: "Test3",
+ version: nil,
+ repositories: ["https://repos/test3/product/"],
+ mandatory_patterns: [],
+ optional_patterns: [],
+ mandatory_packages: [],
+ optional_packages: [],
+ translations: {}
+ )
+ )
+ end
+ end
+
+ context "for s390" do
+ before do
+ allow(Yast::Arch).to receive("x86_64").and_return(false)
+ allow(Yast::Arch).to receive("aarch64").and_return(false)
+ allow(Yast::Arch).to receive("ppc64").and_return(false)
+ allow(Yast::Arch).to receive("s390").and_return(true)
+ end
+
+ it "generates products according to the current architecture" do
+ products = subject.build
+
+ expect(products).to all(be_a(Agama::Software::Product))
+
+ expect(products).to contain_exactly(
+ an_object_having_attributes(
+ id: "Test1",
+ display_name: "Product Test 1",
+ description: "This is a test product named Test 1",
+ name: "Test1",
+ version: "1.0",
+ repositories: [],
+ mandatory_patterns: ["pattern1-1", "pattern1-2"],
+ optional_patterns: [],
+ mandatory_packages: ["package1-1", "package1-2"],
+ optional_packages: ["package1-5"],
+ translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } }
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/service/test/agama/software/product_test.rb b/service/test/agama/software/product_test.rb
new file mode 100644
index 0000000000..aab6a8397e
--- /dev/null
+++ b/service/test/agama/software/product_test.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# Copyright (c) [2023] SUSE LLC
+#
+# All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of version 2 of the GNU General Public License as published
+# by the Free Software Foundation.
+#
+# 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.
+
+require_relative "../../test_helper"
+require "agama/software/product"
+
+describe Agama::Software::Product do
+ subject { described_class.new("Test") }
+
+ describe "#localized_description" do
+ before do
+ subject.description = "Original description"
+ subject.translations = {
+ "description" => {
+ "cs" => "Czech translation",
+ "es" => "Spanish translation"
+ }
+ }
+ end
+
+ it "returns untranslated description when the language is not set" do
+ allow(ENV).to receive(:[]).with("LANG").and_return(nil)
+
+ expect(subject.localized_description).to eq("Original description")
+ end
+
+ it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do
+ allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8")
+
+ expect(subject.localized_description).to eq("Czech translation")
+ end
+
+ it "returns Czech translation if locale is \"cs\"" do
+ allow(ENV).to receive(:[]).with("LANG").and_return("cs")
+
+ expect(subject.localized_description).to eq("Czech translation")
+ end
+
+ it "return untranslated description when translation is not available" do
+ allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8")
+ subject.translations = {}
+
+ expect(subject.localized_description).to eq("Original description")
+ end
+ end
+end
diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb
index 3c850b466f..8dfdf1cb94 100644
--- a/service/test/agama/software/proposal_test.rb
+++ b/service/test/agama/software/proposal_test.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2022] SUSE LLC
+# Copyright (c) [2022-2023] SUSE LLC
#
# All Rights Reserved.
#
@@ -77,9 +77,9 @@
end
context "when no errors were reported" do
- it "does not register any error" do
+ it "does not register any issue" do
subject.calculate
- expect(subject.errors).to be_empty
+ expect(subject.issues).to be_empty
end
end
@@ -88,10 +88,10 @@
{ "warning_level" => :blocker, "warning" => "Could not install..." }
end
- it "registers the corresponding validation error" do
+ it "registers the corresponding issue" do
subject.calculate
- expect(subject.errors).to eq(
- [Agama::ValidationError.new("Could not install...")]
+ expect(subject.issues).to contain_exactly(
+ an_object_having_attributes({ description: "Could not install..." })
)
end
end
@@ -100,13 +100,11 @@
let(:last_error) { "Solving errors..." }
let(:solve_errors) { 5 }
- it "registers them as validation errors" do
+ it "registers them as issues" do
subject.calculate
- expect(subject.errors).to eq(
- [
- Agama::ValidationError.new("Solving errors..."),
- Agama::ValidationError.new("Found 5 dependency issues.")
- ]
+ expect(subject.issues).to contain_exactly(
+ an_object_having_attributes(description: "Solving errors..."),
+ an_object_having_attributes(description: "Found 5 dependency issues.")
)
end
end
diff --git a/service/test/agama/with_issues_examples.rb b/service/test/agama/with_issues_examples.rb
index e3ce105822..5a68e8180b 100644
--- a/service/test/agama/with_issues_examples.rb
+++ b/service/test/agama/with_issues_examples.rb
@@ -27,8 +27,6 @@
let(:issues) { [Agama::Issue.new("Issue 1"), Agama::Issue.new("Issue 2")] }
it "sets the given list of issues" do
- expect(subject.issues).to be_empty
-
subject.issues = issues
expect(subject.issues).to contain_exactly(
diff --git a/setup-service.sh b/setup-service.sh
index 4760be186f..97c3dcd8af 100755
--- a/setup-service.sh
+++ b/setup-service.sh
@@ -39,7 +39,7 @@ test -f /etc/zypp/repos.d/d_l_python.repo || \
$SUDO zypper --non-interactive \
addrepo https://download.opensuse.org/repositories/devel:/languages:/python/openSUSE_Tumbleweed/ d_l_python
$SUDO zypper --non-interactive --gpg-auto-import-keys install gcc gcc-c++ make openssl-devel ruby-devel \
- python-langtable-data git augeas-devel jemalloc-devel awk || exit 1
+ python-langtable-data git augeas-devel jemalloc-devel awk suseconnect-ruby-bindings || exit 1
# only install cargo if it is not available (avoid conflicts with rustup)
which cargo || $SUDO zypper --non-interactive install cargo
diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes
index f510116a81..d9bdc468d7 100644
--- a/web/package/cockpit-agama.changes
+++ b/web/package/cockpit-agama.changes
@@ -1,3 +1,8 @@
+-------------------------------------------------------------------
+Wed Nov 15 12:32:25 UTC 2023 - José Iván López González
+
+- Add UI for registering a product (gh#openSUSE/agama#869).
+
-------------------------------------------------------------------
Thu Nov 2 07:38:22 UTC 2023 - David Diaz
diff --git a/web/src/App.jsx b/web/src/App.jsx
index c02367ddd4..4099afcd10 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -24,7 +24,7 @@ import { Outlet } from "react-router-dom";
import { _ } from "~/i18n";
import { useInstallerClient, useInstallerClientStatus } from "~/context/installer";
-import { useSoftware } from "./context/software";
+import { useProduct } from "./context/product";
import { STARTUP, INSTALL } from "~/client/phase";
import { BUSY } from "~/client/status";
@@ -39,7 +39,6 @@ import {
ShowTerminalButton,
Sidebar
} from "~/components/core";
-import { ChangeProductLink } from "~/components/software";
import { LanguageSwitcher } from "./components/l10n";
import { Layout, Loading, Title } from "./components/layout";
import { useL10n } from "./context/l10n";
@@ -57,7 +56,7 @@ const ATTEMPTS = 3;
function App() {
const client = useInstallerClient();
const { attempt } = useInstallerClientStatus();
- const { products } = useSoftware();
+ const { products } = useProduct();
const { language } = useL10n();
const [status, setStatus] = useState(undefined);
const [phase, setPhase] = useState(undefined);
@@ -107,7 +106,6 @@ function App() {
<>
-
diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx
index 84092633b6..59b0753521 100644
--- a/web/src/App.test.jsx
+++ b/web/src/App.test.jsx
@@ -31,9 +31,9 @@ jest.mock("~/client");
// list of available products
let mockProducts;
-jest.mock("~/context/software", () => ({
- ...jest.requireActual("~/context/software"),
- useSoftware: () => {
+jest.mock("~/context/product", () => ({
+ ...jest.requireActual("~/context/product"),
+ useProduct: () => {
return {
products: mockProducts,
selectedProduct: null
diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss
index 6b64d2f47b..1f84016877 100644
--- a/web/src/assets/styles/utilities.scss
+++ b/web/src/assets/styles/utilities.scss
@@ -142,6 +142,10 @@
padding: 0;
}
+.p-0 {
+ padding: 0;
+}
+
.no-stack-gutter {
--stack-gutter: 0;
}
diff --git a/web/src/client/index.js b/web/src/client/index.js
index b4d56f18e0..34a656972e 100644
--- a/web/src/client/index.js
+++ b/web/src/client/index.js
@@ -30,7 +30,6 @@ import { UsersClient } from "./users";
import phase from "./phase";
import { QuestionsClient } from "./questions";
import { NetworkClient } from "./network";
-import { IssuesClient } from "./issues";
import cockpit from "../lib/cockpit";
const BUS_ADDRESS_FILE = "/run/agama/bus.address";
@@ -38,21 +37,34 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1";
/**
* @typedef {object} InstallerClient
- * @property {LanguageClient} language - language client
- * @property {ManagerClient} manager - manager client
- * @property {Monitor} monitor - service monitor
- * @property {NetworkClient} network - network client
- * @property {SoftwareClient} software - software client
- * @property {StorageClient} storage - storage client
- * @property {UsersClient} users - users client
- * @property {QuestionsClient} questions - questions client
- * @property {IssuesClient} issues - issues client
+ * @property {LanguageClient} language - language client.
+ * @property {ManagerClient} manager - manager client.
+ * @property {Monitor} monitor - service monitor.
+ * @property {NetworkClient} network - network client.
+ * @property {SoftwareClient} software - software client.
+ * @property {StorageClient} storage - storage client.
+ * @property {UsersClient} users - users client.
+ * @property {QuestionsClient} questions - questions client.
+ * @property {() => Promise} issues - issues from all contexts.
+ * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run
+ * when issues from any context change. It returns a function to deregister the handler.
* @property {() => Promise} isConnected - determines whether the client is connected
* @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run
* when the connection is lost. It returns a function to deregister the
* handler.
*/
+/**
+ * @typedef {import ("~/client/mixins").Issue} Issue
+ *
+ * @typedef {object} Issues
+ * @property {Issue[]} [product] - Issues from product.
+ * @property {Issue[]} [storage] - Issues from storage.
+ * @property {Issue[]} [software] - Issues from software.
+ *
+ * @typedef {(issues: Issues) => void} IssuesHandler
+*/
+
/**
* Creates the Agama client
*
@@ -67,7 +79,38 @@ const createClient = (address = "unix:path=/run/agama/bus") => {
const storage = new StorageClient(address);
const users = new UsersClient(address);
const questions = new QuestionsClient(address);
- const issues = new IssuesClient({ storage });
+
+ /**
+ * Gets all issues, grouping them by context.
+ *
+ * TODO: issues are requested by several components (e.g., overview sections, notifications
+ * provider, issues page, storage page, etc). There should be an issues provider.
+ *
+ * @returns {Promise}
+ */
+ const issues = async () => {
+ return {
+ product: await software.product.getIssues(),
+ storage: await storage.getIssues(),
+ software: await software.getIssues()
+ };
+ };
+
+ /**
+ * Registers a callback to be executed when issues change.
+ *
+ * @param {IssuesHandler} handler - Callback function.
+ * @return {() => void} - Function to deregister the callback.
+ */
+ const onIssuesChange = (handler) => {
+ const unsubscribeCallbacks = [];
+
+ unsubscribeCallbacks.push(software.product.onIssuesChange(i => handler({ product: i })));
+ unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i })));
+ unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i })));
+
+ return () => { unsubscribeCallbacks.forEach(cb => cb()) };
+ };
const isConnected = async () => {
try {
@@ -88,6 +131,7 @@ const createClient = (address = "unix:path=/run/agama/bus") => {
users,
questions,
issues,
+ onIssuesChange,
isConnected,
onDisconnect: (handler) => monitor.onDisconnect(handler)
};
diff --git a/web/src/client/issues.js b/web/src/client/issues.js
deleted file mode 100644
index f6a38d4e26..0000000000
--- a/web/src/client/issues.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright (c) [2023] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of version 2 of the GNU General Public License as published
- * by the Free Software Foundation.
- *
- * 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.
- */
-
-// @ts-check
-
-/**
- * @typedef {object} ClientsIssues
- * @property {import ("~/client/mixins").Issue[]} storage - Issues from storage client
- */
-
-/**
- * Client for managing all issues, independently on the service owning the issues
- */
-class IssuesClient {
- /**
- * @param {object} clients - Clients managing issues
- * @param {import ("~/client/storage").StorageClient} clients.storage
- */
- constructor(clients) {
- this.clients = clients;
- }
-
- /**
- * Get issues from all clients managing issues
- *
- * @returns {Promise}
- */
- async getAll() {
- const storage = await this.clients.storage.getIssues();
-
- return { storage };
- }
-
- /**
- * Checks whether there is some error
- *
- * @returns {Promise}
- */
- async any() {
- const clientsIssues = await this.getAll();
- const issues = Object.values(clientsIssues).flat();
-
- return issues.length > 0;
- }
-
- /**
- * Registers a callback for each service to be executed when its issues change
- *
- * @param {import ("~/client/mixins").IssuesHandler} handler - callback function
- * @return {import ("./dbus").RemoveFn} function to disable the callback
- */
- onIssuesChange(handler) {
- const unsubscribeCallbacks = [];
- unsubscribeCallbacks.push(this.clients.storage.onIssuesChange(handler));
-
- return () => { unsubscribeCallbacks.forEach(cb => cb()) };
- }
-}
-
-export { IssuesClient };
diff --git a/web/src/client/issues.test.js b/web/src/client/issues.test.js
deleted file mode 100644
index 2eec15df3f..0000000000
--- a/web/src/client/issues.test.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright (c) [2023] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of version 2 of the GNU General Public License as published
- * by the Free Software Foundation.
- *
- * 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.
- */
-
-// @ts-check
-
-import { IssuesClient } from "./issues";
-import { StorageClient } from "./storage";
-
-const storageIssues = [
- { description: "Storage issue 1", severity: "error", details: "", source: "" },
- { description: "Storage issue 2", severity: "warn", details: "", source: "" },
- { description: "Storage issue 3", severity: "error", details: "", source: "" }
-];
-
-const issues = {
- storage: []
-};
-
-jest.spyOn(StorageClient.prototype, 'getIssues').mockImplementation(async () => issues.storage);
-jest.spyOn(StorageClient.prototype, 'onIssuesChange');
-
-const clientsWithIssues = {
- storage: new StorageClient()
-};
-
-describe("#getAll", () => {
- beforeEach(() => {
- issues.storage = storageIssues;
- });
-
- it("returns all the storage issues", async () => {
- const client = new IssuesClient(clientsWithIssues);
-
- const { storage } = await client.getAll();
- expect(storage).toEqual(expect.arrayContaining(storageIssues));
- });
-});
-
-describe("#any", () => {
- describe("if there are storage issues", () => {
- beforeEach(() => {
- issues.storage = storageIssues;
- });
-
- it("returns true", async () => {
- const client = new IssuesClient(clientsWithIssues);
-
- const result = await client.any();
- expect(result).toEqual(true);
- });
- });
-
- describe("if there are no issues", () => {
- beforeEach(() => {
- issues.storage = [];
- });
-
- it("returns false", async () => {
- const client = new IssuesClient(clientsWithIssues);
-
- const result = await client.any();
- expect(result).toEqual(false);
- });
- });
-});
-
-describe("#onIssuesChange", () => {
- it("subscribes to changes in storage issues", () => {
- const client = new IssuesClient(clientsWithIssues);
-
- const handler = jest.fn();
- client.onIssuesChange(handler);
-
- expect(clientsWithIssues.storage.onIssuesChange).toHaveBeenCalledWith(handler);
- });
-});
diff --git a/web/src/client/software.js b/web/src/client/software.js
index 5572033707..edec2fcc21 100644
--- a/web/src/client/software.js
+++ b/web/src/client/software.js
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022] SUSE LLC
+ * Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
@@ -22,11 +22,14 @@
// @ts-check
import DBusClient from "./dbus";
-import { WithStatus, WithProgress, WithValidation } from "./mixins";
+import { WithIssues, WithStatus, WithProgress } from "./mixins";
const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1";
const SOFTWARE_IFACE = "org.opensuse.Agama.Software1";
const SOFTWARE_PATH = "/org/opensuse/Agama/Software1";
+const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product";
+const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product";
+const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration";
/**
* @typedef {object} Product
@@ -35,6 +38,175 @@ const SOFTWARE_PATH = "/org/opensuse/Agama/Software1";
* @property {string} description - Product description
*/
+/**
+ * @typedef {object} Registration
+ * @property {string} requirement - Registration requirement (i.e., "not-required", "optional",
+ * "mandatory").
+ * @property {string|null} code - Registration code, if any.
+ * @property {string|null} email - Registration email, if any.
+ */
+
+/**
+ * @typedef {object} ActionResult
+ * @property {boolean} success - Whether the action was successfully done.
+ * @property {string} message - Result message.
+ */
+
+/**
+ * Product manager.
+ * @ignore
+ */
+class BaseProductManager {
+ /**
+ * @param {DBusClient} client
+ */
+ constructor(client) {
+ this.client = client;
+ }
+
+ /**
+ * Returns the list of available products.
+ *
+ * @return {Promise>}
+ */
+ async getAll() {
+ const proxy = await this.client.proxy(PRODUCT_IFACE);
+ return proxy.AvailableProducts.map(product => {
+ const [id, name, meta] = product;
+ return { id, name, description: meta.description?.v };
+ });
+ }
+
+ /**
+ * Returns the selected product.
+ *
+ * @return {Promise}
+ */
+ async getSelected() {
+ const products = await this.getAll();
+ const proxy = await this.client.proxy(PRODUCT_IFACE);
+ if (proxy.SelectedProduct === "") {
+ return null;
+ }
+ return products.find(product => product.id === proxy.SelectedProduct);
+ }
+
+ /**
+ * Selects a product for installation.
+ *
+ * @param {string} id - Product ID.
+ */
+ async select(id) {
+ const proxy = await this.client.proxy(PRODUCT_IFACE);
+ return proxy.SelectProduct(id);
+ }
+
+ /**
+ * Registers a callback to run when properties in the Product object change.
+ *
+ * @param {(id: string) => void} handler - Callback function.
+ */
+ onChange(handler) {
+ return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => {
+ if ("SelectedProduct" in changes) {
+ const selected = changes.SelectedProduct.v.toString();
+ handler(selected);
+ }
+ });
+ }
+
+ /**
+ * Returns the registration of the selected product.
+ *
+ * @return {Promise}
+ */
+ async getRegistration() {
+ const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH);
+ const requirement = this.registrationRequirement(proxy.Requirement);
+ const code = proxy.RegCode;
+ const email = proxy.Email;
+
+ const registration = { requirement, code, email };
+ if (code.length === 0) registration.code = null;
+ if (email.length === 0) registration.email = null;
+
+ return registration;
+ }
+
+ /**
+ * Tries to register the selected product.
+ *
+ * @param {string} code
+ * @param {string} [email]
+ * @returns {Promise}
+ */
+ async register(code, email = "") {
+ const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH);
+ const result = await proxy.Register(code, { Email: { t: "s", v: email } });
+
+ return {
+ success: result[0] === 0,
+ message: result[1]
+ };
+ }
+
+ /**
+ * Tries to deregister the selected product.
+ *
+ * @returns {Promise}
+ */
+ async deregister() {
+ const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH);
+ const result = await proxy.Deregister();
+
+ return {
+ success: result[0] === 0,
+ message: result[1]
+ };
+ }
+
+ /**
+ * Registers a callback to run when the registration changes.
+ *
+ * @param {(registration: Registration) => void} handler - Callback function.
+ */
+ onRegistrationChange(handler) {
+ return this.client.onObjectChanged(PRODUCT_PATH, REGISTRATION_IFACE, () => {
+ this.getRegistration().then(handler);
+ });
+ }
+
+ /**
+ * Helper method to generate the requirement representation.
+ * @private
+ *
+ * @param {number} value - D-Bus registration value.
+ * @returns {string}
+ */
+ registrationRequirement(value) {
+ let requirement;
+
+ switch (value) {
+ case 0:
+ requirement = "not-required";
+ break;
+ case 1:
+ requirement = "optional";
+ break;
+ case 2:
+ requirement = "mandatory";
+ break;
+ }
+
+ return requirement;
+ }
+}
+
+/**
+ * Manages product selection.
+ */
+class ProductManager extends WithIssues(BaseProductManager, PRODUCT_PATH) { }
+
/**
* Software client
*
@@ -46,6 +218,7 @@ class SoftwareBaseClient {
*/
constructor(address = undefined) {
this.client = new DBusClient(SOFTWARE_SERVICE, address);
+ this.product = new ProductManager(this.client);
}
/**
@@ -58,19 +231,6 @@ class SoftwareBaseClient {
return proxy.Probe();
}
- /**
- * Returns the list of available products
- *
- * @return {Promise>}
- */
- async getProducts() {
- const proxy = await this.client.proxy(SOFTWARE_IFACE);
- return proxy.AvailableBaseProducts.map(product => {
- const [id, name, meta] = product;
- return { id, name, description: meta.description?.v };
- });
- }
-
/**
* Returns how much space installation takes on disk
*
@@ -128,50 +288,12 @@ class SoftwareBaseClient {
const proxy = await this.client.proxy(SOFTWARE_IFACE);
return proxy.RemovePattern(name);
}
-
- /**
- * Returns the selected product
- *
- * @return {Promise}
- */
- async getSelectedProduct() {
- const products = await this.getProducts();
- const proxy = await this.client.proxy(SOFTWARE_IFACE);
- if (proxy.SelectedBaseProduct === "") {
- return null;
- }
- return products.find(product => product.id === proxy.SelectedBaseProduct);
- }
-
- /**
- * Selects a product for installation
- *
- * @param {string} id - product ID
- */
- async selectProduct(id) {
- const proxy = await this.client.proxy(SOFTWARE_IFACE);
- return proxy.SelectProduct(id);
- }
-
- /**
- * Registers a callback to run when properties in the Software object change
- *
- * @param {(id: string) => void} handler - callback function
- */
- onProductChange(handler) {
- return this.client.onObjectChanged(SOFTWARE_PATH, SOFTWARE_IFACE, changes => {
- if ("SelectedBaseProduct" in changes) {
- const selected = changes.SelectedBaseProduct.v.toString();
- handler(selected);
- }
- });
- }
}
/**
- * Allows getting the list the available products and selecting one for installation.
+ * Manages software and product configuration.
*/
-class SoftwareClient extends WithValidation(
+class SoftwareClient extends WithIssues(
WithProgress(
WithStatus(SoftwareBaseClient, SOFTWARE_PATH), SOFTWARE_PATH
), SOFTWARE_PATH
diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js
index f8460beb43..dfcdaeaed7 100644
--- a/web/src/client/software.test.js
+++ b/web/src/client/software.test.js
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022] SUSE LLC
+ * Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
@@ -26,45 +26,166 @@ import { SoftwareClient } from "./software";
jest.mock("./dbus");
-const SOFTWARE_IFACE = "org.opensuse.Agama.Software1";
+const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product";
+const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration";
-const softProxy = {
+const productProxy = {
wait: jest.fn(),
- AvailableBaseProducts: [
+ AvailableProducts: [
["MicroOS", "openSUSE MicroOS", {}],
["Tumbleweed", "openSUSE Tumbleweed", {}]
],
- SelectedBaseProduct: "MicroOS"
+ SelectedProduct: "MicroOS"
};
+const registrationProxy = {};
+
beforeEach(() => {
// @ts-ignore
DBusClient.mockImplementation(() => {
return {
proxy: (iface) => {
- if (iface === SOFTWARE_IFACE) return softProxy;
+ if (iface === PRODUCT_IFACE) return productProxy;
+ if (iface === REGISTRATION_IFACE) return registrationProxy;
}
};
});
});
-describe("#getProducts", () => {
- it("returns the list of available products", async () => {
- const client = new SoftwareClient();
- const availableProducts = await client.getProducts();
- expect(availableProducts).toEqual([
- { id: "MicroOS", name: "openSUSE MicroOS" },
- { id: "Tumbleweed", name: "openSUSE Tumbleweed" }
- ]);
+describe("#product", () => {
+ describe("#getAll", () => {
+ it("returns the list of available products", async () => {
+ const client = new SoftwareClient();
+ const availableProducts = await client.product.getAll();
+ expect(availableProducts).toEqual([
+ { id: "MicroOS", name: "openSUSE MicroOS" },
+ { id: "Tumbleweed", name: "openSUSE Tumbleweed" }
+ ]);
+ });
});
-});
-describe('#getSelectedProduct', () => {
- it("returns the selected product", async () => {
- const client = new SoftwareClient();
- const selectedProduct = await client.getSelectedProduct();
- expect(selectedProduct).toEqual(
- { id: "MicroOS", name: "openSUSE MicroOS" }
- );
+ describe("#getSelected", () => {
+ it("returns the selected product", async () => {
+ const client = new SoftwareClient();
+ const selectedProduct = await client.product.getSelected();
+ expect(selectedProduct).toEqual(
+ { id: "MicroOS", name: "openSUSE MicroOS" }
+ );
+ });
+ });
+
+ describe("#getRegistration", () => {
+ describe("if the product is not registered yet", () => {
+ beforeEach(() => {
+ registrationProxy.RegCode = "";
+ registrationProxy.Email = "";
+ registrationProxy.Requirement = 1;
+ });
+
+ it("returns the expected registration result", async () => {
+ const client = new SoftwareClient();
+ const registration = await client.product.getRegistration();
+ expect(registration).toStrictEqual({
+ code: null,
+ email: null,
+ requirement: "optional"
+ });
+ });
+ });
+
+ describe("if the product is registered", () => {
+ beforeEach(() => {
+ registrationProxy.RegCode = "111222";
+ registrationProxy.Email = "test@test.com";
+ registrationProxy.Requirement = 2;
+ });
+
+ it("returns the expected registration", async () => {
+ const client = new SoftwareClient();
+ const registration = await client.product.getRegistration();
+ expect(registration).toStrictEqual({
+ code: "111222",
+ email: "test@test.com",
+ requirement: "mandatory"
+ });
+ });
+ });
+ });
+
+ describe("#register", () => {
+ beforeEach(() => {
+ registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]);
+ });
+
+ it("performs the expected D-Bus call", async () => {
+ const client = new SoftwareClient();
+ await client.product.register("111222", "test@test.com");
+ expect(registrationProxy.Register).toHaveBeenCalledWith(
+ "111222",
+ { Email: { t: "s", v: "test@test.com" } }
+ );
+ });
+
+ describe("when the action is correctly done", () => {
+ beforeEach(() => {
+ registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]);
+ });
+
+ it("returns a successful result", async () => {
+ const client = new SoftwareClient();
+ const result = await client.product.register("111222", "test@test.com");
+ expect(result).toStrictEqual({
+ success: true,
+ message: ""
+ });
+ });
+ });
+
+ describe("when the action fails", () => {
+ beforeEach(() => {
+ registrationProxy.Register = jest.fn().mockResolvedValue([1, "error message"]);
+ });
+
+ it("returns an unsuccessful result", async () => {
+ const client = new SoftwareClient();
+ const result = await client.product.register("111222", "test@test.com");
+ expect(result).toStrictEqual({
+ success: false,
+ message: "error message"
+ });
+ });
+ });
+ });
+
+ describe("#deregister", () => {
+ describe("when the action is correctly done", () => {
+ beforeEach(() => {
+ registrationProxy.Deregister = jest.fn().mockResolvedValue([0, ""]);
+ });
+
+ it("returns a successful result", async () => {
+ const client = new SoftwareClient();
+ const result = await client.product.deregister();
+ expect(result).toStrictEqual({
+ success: true,
+ message: ""
+ });
+ });
+ });
+
+ describe("when the action fails", () => {
+ beforeEach(() => {
+ registrationProxy.Deregister = jest.fn().mockResolvedValue([1, "error message"]);
+ });
+
+ it("returns an unsuccessful result", async () => {
+ const client = new SoftwareClient();
+ const result = await client.product.deregister();
+ expect(result).toStrictEqual({
+ success: false,
+ message: "error message"
+ });
+ });
+ });
});
});
diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.jsx
new file mode 100644
index 0000000000..dc84711157
--- /dev/null
+++ b/web/src/components/core/EmailInput.jsx
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 { InputGroup, TextInput } from "@patternfly/react-core";
+import { noop } from "~/utils";
+
+/**
+ * Email validation.
+ *
+ * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js
+ *
+ * @param {string} email
+ * @returns {boolean}
+ */
+const validateEmail = (email) => {
+ const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
+
+ const validateFormat = (email) => {
+ const parts = email.split('@');
+
+ return parts.length === 2 && regexp.test(email);
+ };
+
+ const validateSizes = (email) => {
+ const [account, address] = email.split('@');
+
+ if (account.length > 64) return false;
+ if (address.length > 255) return false;
+
+ const domainParts = address.split('.');
+
+ if (domainParts.find(p => p.length > 63)) return false;
+
+ return true;
+ };
+
+ return validateFormat(email) && validateSizes(email);
+};
+
+/**
+ * Renders an email input field which validates its value.
+ * @component
+ *
+ * @param {(boolean) => void} onValidate - Callback to be called every time the input value is
+ * validated.
+ * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput},
+ * except `type` and `validated` which are managed by the component.
+ */
+export default function EmailInput({ onValidate = noop, ...props }) {
+ const [isValid, setIsValid] = useState(true);
+
+ useEffect(() => {
+ const isValid = props.value.length === 0 || validateEmail(props.value);
+ setIsValid(isValid);
+ onValidate(isValid);
+ }, [onValidate, props.value, setIsValid]);
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.jsx
new file mode 100644
index 0000000000..0c64ce08b7
--- /dev/null
+++ b/web/src/components/core/EmailInput.test.jsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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, { useState } from "react";
+import { screen } from "@testing-library/react";
+
+import EmailInput from "./EmailInput";
+import { plainRender } from "~/test-utils";
+
+describe("EmailInput component", () => {
+ it("renders an email input", () => {
+ plainRender(
+
+ );
+
+ const inputField = screen.getByRole('textbox', { name: "User email" });
+ expect(inputField).toHaveAttribute("type", "email");
+ });
+
+ // Using a controlled component for testing the rendered result instead of testing if
+ // the given onChange callback is called. The former is more aligned with the
+ // React Testing Library principles, https://testing-library.com/docs/guiding-principles/
+ const EmailInputTest = (props) => {
+ const [email, setEmail] = useState("");
+ const [isValid, setIsValid] = useState(true);
+
+ return (
+ <>
+ setEmail(v)}
+ onValidate={setIsValid}
+ />
+ {email &&
Email value updated!
}
+ {isValid === false &&
Email is not valid!
}
+ >
+ );
+ };
+
+ it("triggers onChange callback", async () => {
+ const { user } = plainRender();
+ const emailInput = screen.getByRole('textbox', { name: "Test email" });
+
+ expect(screen.queryByText("Email value updated!")).toBeNull();
+
+ await user.type(emailInput, "test@test.com");
+ screen.getByText("Email value updated!");
+ });
+
+ it("triggers onValidate callback", async () => {
+ const { user } = plainRender();
+ const emailInput = screen.getByRole('textbox', { name: "Test email" });
+
+ expect(screen.queryByText("Email is not valid!")).toBeNull();
+
+ await user.type(emailInput, "foo");
+ await screen.findByText("Email is not valid!");
+ });
+
+ it("marks the input as invalid if the value is not a valid email", async () => {
+ const { user } = plainRender();
+ const emailInput = screen.getByRole('textbox', { name: "Test email" });
+
+ await user.type(emailInput, "foo");
+
+ expect(emailInput).toHaveAttribute("aria-invalid");
+ });
+});
diff --git a/web/src/components/core/IssuesLink.test.jsx b/web/src/components/core/IssuesLink.test.jsx
index dc2fdae1ba..8073276beb 100644
--- a/web/src/components/core/IssuesLink.test.jsx
+++ b/web/src/components/core/IssuesLink.test.jsx
@@ -25,17 +25,15 @@ import { installerRender, withNotificationProvider } from "~/test-utils";
import { createClient } from "~/client";
import { IssuesLink } from "~/components/core";
-let hasIssues = false;
+let mockIssues = {};
jest.mock("~/client");
beforeEach(() => {
createClient.mockImplementation(() => {
return {
- issues: {
- any: () => Promise.resolve(hasIssues),
- onIssuesChange: jest.fn()
- }
+ issues: jest.fn().mockResolvedValue(mockIssues),
+ onIssuesChange: jest.fn()
};
});
});
@@ -48,7 +46,9 @@ it("renders a link for navigating to the issues page", async () => {
describe("if there are issues", () => {
beforeEach(() => {
- hasIssues = true;
+ mockIssues = {
+ storage: [{ description: "issue 1" }]
+ };
});
it("includes a notification mark", async () => {
@@ -60,7 +60,7 @@ describe("if there are issues", () => {
describe("if there are not issues", () => {
beforeEach(() => {
- hasIssues = false;
+ mockIssues = {};
});
it("does not include a notification mark", async () => {
diff --git a/web/src/components/core/IssuesPage.jsx b/web/src/components/core/IssuesPage.jsx
index 7d137c5fca..f076c6aece 100644
--- a/web/src/components/core/IssuesPage.jsx
+++ b/web/src/components/core/IssuesPage.jsx
@@ -21,17 +21,17 @@
import React, { useCallback, useEffect, useState } from "react";
-import { HelperText, HelperTextItem, Skeleton } from "@patternfly/react-core";
+import { HelperText, HelperTextItem } from "@patternfly/react-core";
import { partition, useCancellablePromise } from "~/utils";
-import { If, Page, Section } from "~/components/core";
+import { If, Page, Section, SectionSkeleton } from "~/components/core";
import { Icon } from "~/components/layout";
import { useInstallerClient } from "~/context/installer";
import { useNotification } from "~/context/notification";
import { _ } from "~/i18n";
/**
- * Renders an issue
+ * Item representing an issue.
* @component
*
* @param {object} props
@@ -58,54 +58,68 @@ const IssueItem = ({ issue }) => {
};
/**
- * Generates a specific section with issues
+ * Generates issue items sorted by severity.
* @component
*
* @param {object} props
* @param {import ("~/client/mixins").Issue[]} props.issues
- * @param {object} props.props
*/
-const IssuesSection = ({ issues, ...props }) => {
- if (issues.length === 0) return null;
-
+const IssueItems = ({ issues = [] }) => {
const sortedIssues = partition(issues, i => i.severity === "error").flat();
- const issueItems = sortedIssues.map((issue, index) => {
+ return sortedIssues.map((issue, index) => {
return ;
});
-
- return (
-
- {issueItems}
-
- );
};
/**
- * Generates the sections with issues
+ * Generates the sections with issues.
* @component
*
* @param {object} props
* @param {import ("~/client/issues").ClientsIssues} props.issues
*/
const IssuesSections = ({ issues }) => {
+ const productIssues = issues.product || [];
+ const storageIssues = issues.storage || [];
+ const softwareIssues = issues.software || [];
+
return (
-
+ <>
+ 0}
+ then={
+
+
+
+ }
+ />
+ 0}
+ then={
+
+
+
+ }
+ />
+ 0}
+ then={
+
+
+
+ }
+ />
+ >
);
};
/**
- * Generates the content for each section with issues. If there are no issues, then a success
- * message is shown.
+ * Generates sections with issues. If there are no issues, then a success message is shown.
* @component
*
* @param {object} props
- * @param {import ("~/client/issues").ClientsIssues} props.issues
+ * @param {import ("~/client").Issues} props.issues
*/
const IssuesContent = ({ issues }) => {
const NoIssues = () => {
@@ -130,28 +144,32 @@ const IssuesContent = ({ issues }) => {
};
/**
- * Page to show all issues per section
+ * Page to show all issues.
* @component
*/
export default function IssuesPage() {
const [isLoading, setIsLoading] = useState(true);
- const [issues, setIssues] = useState({});
- const { issues: client } = useInstallerClient();
+ const [issues, setIssues] = useState();
+ const client = useInstallerClient();
const { cancellablePromise } = useCancellablePromise();
- const [, updateNotification] = useNotification();
+ const [notification, updateNotification] = useNotification();
- const loadIssues = useCallback(async () => {
+ const load = useCallback(async () => {
setIsLoading(true);
- const allIssues = await cancellablePromise(client.getAll());
- setIssues(allIssues);
+ const issues = await cancellablePromise(client.issues());
setIsLoading(false);
- updateNotification({ issues: false });
- }, [client, cancellablePromise, setIssues, setIsLoading, updateNotification]);
+ return issues;
+ }, [client, cancellablePromise, setIsLoading]);
+
+ const update = useCallback((issues) => {
+ setIssues(current => ({ ...current, ...issues }));
+ if (notification.issues) updateNotification({ issues: false });
+ }, [notification, setIssues, updateNotification]);
useEffect(() => {
- loadIssues();
- return client.onIssuesChange(loadIssues);
- }, [client, loadIssues]);
+ load().then(update);
+ return client.onIssuesChange(update);
+ }, [client, load, update]);
return (
}
+ then={}
else={}
/>
diff --git a/web/src/components/core/IssuesPage.test.jsx b/web/src/components/core/IssuesPage.test.jsx
index 061357e68c..97badf900d 100644
--- a/web/src/components/core/IssuesPage.test.jsx
+++ b/web/src/components/core/IssuesPage.test.jsx
@@ -20,37 +20,43 @@
*/
import React from "react";
-import { screen, within } from "@testing-library/react";
-import { installerRender, withNotificationProvider } from "~/test-utils";
+import { act, screen, waitFor, within } from "@testing-library/react";
+import { installerRender, createCallbackMock, withNotificationProvider } from "~/test-utils";
import { createClient } from "~/client";
import { IssuesPage } from "~/components/core";
jest.mock("~/client");
jest.mock("@patternfly/react-core", () => {
- const original = jest.requireActual("@patternfly/react-core");
-
return {
- ...original,
+ ...jest.requireActual("@patternfly/react-core"),
Skeleton: () =>
);
@@ -68,9 +69,10 @@ beforeEach(() => {
describe("when product is selected", () => {
it("renders the Overview and the Install button", async () => {
installerRender();
- const title = screen.getByText(/openSUSE Tumbleweed/i);
+ const title = screen.getByText(/installation summary/i);
expect(title).toBeInTheDocument();
+ await screen.findByText("Product Section");
await screen.findByText("Localization Section");
await screen.findByText("Network Section");
await screen.findByText("Storage Section");
diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx
new file mode 100644
index 0000000000..83121fab21
--- /dev/null
+++ b/web/src/components/overview/ProductSection.jsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 { Text } from "@patternfly/react-core";
+import { sprintf } from "sprintf-js";
+
+import { toValidationError, useCancellablePromise } from "~/utils";
+import { useInstallerClient } from "~/context/installer";
+import { useProduct } from "~/context/product";
+import { Section, SectionSkeleton } from "~/components/core";
+import { _ } from "~/i18n";
+
+const errorsFrom = (issues) => {
+ const errors = issues.filter(i => i.severity === "error");
+ return errors.map(toValidationError);
+};
+
+const Content = ({ isLoading = false }) => {
+ const { registration, selectedProduct } = useProduct();
+
+ if (isLoading) return ;
+
+ const isRegistered = registration?.code !== null;
+ const productName = selectedProduct?.name;
+
+ return (
+
+ {/* TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) */}
+ {isRegistered ? sprintf(_("%s (registered)"), productName) : productName}
+
+ );
+};
+
+export default function ProductSection() {
+ const { software } = useInstallerClient();
+ const [issues, setIssues] = useState([]);
+ const { selectedProduct } = useProduct();
+ const { cancellablePromise } = useCancellablePromise();
+
+ useEffect(() => {
+ cancellablePromise(software.product.getIssues()).then(setIssues);
+ return software.product.onIssuesChange(setIssues);
+ }, [cancellablePromise, setIssues, software]);
+
+ const isLoading = !selectedProduct;
+ const errors = isLoading ? [] : errorsFrom(issues);
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/overview/ProductSection.test.jsx b/web/src/components/overview/ProductSection.test.jsx
new file mode 100644
index 0000000000..17edd0b62f
--- /dev/null
+++ b/web/src/components/overview/ProductSection.test.jsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 } from "~/test-utils";
+import { createClient } from "~/client";
+import { ProductSection } from "~/components/overview";
+
+let mockRegistration;
+let mockSelectedProduct;
+
+const mockIssue = { severity: "error", description: "Fake issue" };
+
+jest.mock("~/client");
+
+jest.mock("~/components/core/SectionSkeleton", () => () =>
Loading
);
+
+jest.mock("~/context/product", () => ({
+ ...jest.requireActual("~/context/product"),
+ useProduct: () => ({
+ registration: mockRegistration,
+ selectedProduct: mockSelectedProduct
+ })
+}));
+
+beforeEach(() => {
+ const issues = [mockIssue];
+ mockRegistration = {};
+ mockSelectedProduct = { name: "Test Product" };
+
+ createClient.mockImplementation(() => {
+ return {
+ software: {
+ product: {
+ getIssues: jest.fn().mockResolvedValue(issues),
+ onIssuesChange: jest.fn()
+ }
+ }
+ };
+ });
+});
+
+it("shows the product name", async () => {
+ installerRender();
+
+ await screen.findByText(/Test Product/);
+ await waitFor(() => expect(screen.queryByText("registered")).not.toBeInTheDocument());
+});
+
+it("indicates whether the product is registered", async () => {
+ mockRegistration = { code: "111222" };
+ installerRender();
+
+ await screen.findByText(/Test Product \(registered\)/);
+});
+
+it("shows the error", async () => {
+ installerRender();
+
+ await screen.findByText("Fake issue");
+});
+
+it("does not show warnings", async () => {
+ mockIssue.severity = "warning";
+
+ installerRender();
+
+ await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument());
+});
+
+describe("when no product is selected", () => {
+ beforeEach(() => {
+ mockSelectedProduct = undefined;
+ });
+
+ it("shows the skeleton", async () => {
+ installerRender();
+
+ await screen.findByText("Loading");
+ });
+
+ it("does not show errors", async () => {
+ installerRender();
+
+ await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument());
+ });
+});
diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx
index 2dd432489b..fbcd81cee8 100644
--- a/web/src/components/overview/SoftwareSection.jsx
+++ b/web/src/components/overview/SoftwareSection.jsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022] SUSE LLC
+ * Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
@@ -20,13 +20,13 @@
*/
import React, { useReducer, useEffect } from "react";
+import { BUSY } from "~/client/status";
import { Button } from "@patternfly/react-core";
-import { ProgressText, Section } from "~/components/core";
import { Icon } from "~/components/layout";
+import { ProgressText, Section } from "~/components/core";
+import { toValidationError, useCancellablePromise } from "~/utils";
import { UsedSize } from "~/components/software";
-import { useCancellablePromise } from "~/utils";
import { useInstallerClient } from "~/context/installer";
-import { BUSY } from "~/client/status";
import { _ } from "~/i18n";
const initialState = {
@@ -78,13 +78,9 @@ export default function SoftwareSection({ showErrors }) {
return client.onStatusChange(updateStatus);
}, [client, cancellablePromise]);
- useEffect(() => {
- cancellablePromise(client.getStatus()).then(updateStatus);
- }, [client, cancellablePromise]);
-
useEffect(() => {
const updateProposal = async () => {
- const errors = await cancellablePromise(client.getValidationErrors());
+ const errors = await cancellablePromise(client.getIssues());
const size = await cancellablePromise(client.getUsedSpace());
dispatch({ type: "UPDATE_PROPOSAL", payload: { errors, size } });
@@ -145,7 +141,7 @@ export default function SoftwareSection({ showErrors }) {
title={_("Software")}
icon="apps"
loading={state.busy}
- errors={errors}
+ errors={errors.map(toValidationError)}
path="/software"
>
diff --git a/web/src/components/overview/SoftwareSection.test.jsx b/web/src/components/overview/SoftwareSection.test.jsx
index 1299e09bda..d21df0e84c 100644
--- a/web/src/components/overview/SoftwareSection.test.jsx
+++ b/web/src/components/overview/SoftwareSection.test.jsx
@@ -31,7 +31,7 @@ jest.mock("~/client");
let getStatusFn = jest.fn().mockResolvedValue(IDLE);
let getProgressFn = jest.fn().mockResolvedValue({});
-let getValidationErrorsFn = jest.fn().mockResolvedValue([]);
+let getIssuesFn = jest.fn().mockResolvedValue([]);
beforeEach(() => {
createClient.mockImplementation(() => {
@@ -39,7 +39,7 @@ beforeEach(() => {
software: {
getStatus: getStatusFn,
getProgress: getProgressFn,
- getValidationErrors: getValidationErrorsFn,
+ getIssues: getIssuesFn,
onStatusChange: noop,
onProgressChange: noop,
getUsedSpace: jest.fn().mockResolvedValue("500 MB")
@@ -48,7 +48,7 @@ beforeEach(() => {
});
});
-describe("when there proposal is calculated", () => {
+describe("when the proposal is calculated", () => {
beforeEach(() => {
getStatusFn = jest.fn().mockResolvedValue(IDLE);
});
@@ -61,7 +61,7 @@ describe("when there proposal is calculated", () => {
describe("and there are errors", () => {
beforeEach(() => {
- getValidationErrorsFn = jest.fn().mockResolvedValue([{ message: "Could not install..." }]);
+ getIssuesFn = jest.fn().mockResolvedValue([{ description: "Could not install..." }]);
});
it("renders a button to refresh the repositories", async () => {
diff --git a/web/src/components/overview/index.js b/web/src/components/overview/index.js
index bfab42832b..c46f96127a 100644
--- a/web/src/components/overview/index.js
+++ b/web/src/components/overview/index.js
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022] SUSE LLC
+ * Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
@@ -22,6 +22,7 @@
export { default as Overview } from "./Overview";
export { default as L10nSection } from "./L10nSection";
export { default as NetworkSection } from "./NetworkSection";
+export { default as ProductSection } from "./ProductSection";
export { default as SoftwareSection } from "./SoftwareSection";
export { default as StorageSection } from "./StorageSection";
export { default as UsersSection } from "./UsersSection";
diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx
new file mode 100644
index 0000000000..77c671f855
--- /dev/null
+++ b/web/src/components/product/ProductPage.jsx
@@ -0,0 +1,439 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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.
+ */
+
+// cspell:ignore Deregistration
+
+import React, { useEffect, useState } from "react";
+import { Alert, Button, Form } from "@patternfly/react-core";
+import { sprintf } from "sprintf-js";
+
+import { _ } from "~/i18n";
+import { BUSY } from "~/client/status";
+import { If, Page, Popup, Section } from "~/components/core";
+import { noop, useCancellablePromise } from "~/utils";
+import { ProductRegistrationForm, ProductSelector } from "~/components/product";
+import { useInstallerClient } from "~/context/installer";
+import { useProduct } from "~/context/product";
+
+/**
+ * Popup for selecting a product.
+ * @component
+ *
+ * @param {object} props
+ * @param {boolean} props.isOpen
+ * @param {function} props.onFinish - Callback to be called when the product is correctly selected.
+ * @param {function} props.onCancel - Callback to be called when the product selection is canceled.
+ */
+const ChangeProductPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => {
+ const { manager, software } = useInstallerClient();
+ const { products, selectedProduct } = useProduct();
+ const [newProductId, setNewProductId] = useState(selectedProduct?.id);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+
+ if (newProductId !== selectedProduct?.id) {
+ await software.product.select(newProductId);
+ manager.startProbing();
+ }
+
+ onFinish();
+ };
+
+ return (
+
+
+
+
+ {_("Accept")}
+
+
+
+
+ );
+};
+
+/**
+ * Popup for registering a product.
+ * @component
+ *
+ * @param {object} props
+ * @param {boolean} props.isOpen
+ * @param {function} props.onFinish - Callback to be called when the product is correctly
+ * registered.
+ * @param {function} props.onCancel - Callback to be called when the product registration is
+ * canceled.
+ */
+const RegisterProductPopup = ({
+ isOpen = false,
+ onFinish = noop,
+ onCancel: onCancelProp = noop
+}) => {
+ const { software } = useInstallerClient();
+ const { selectedProduct } = useProduct();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFormValid, setIsFormValid] = useState(true);
+ const [error, setError] = useState();
+
+ const onSubmit = async ({ code, email }) => {
+ setIsLoading(true);
+ const result = await software.product.register(code, email);
+ setIsLoading(false);
+ if (result.success) {
+ software.probe();
+ onFinish();
+ } else {
+ setError(result.message);
+ }
+ };
+
+ const onCancel = () => {
+ setError(null);
+ onCancelProp();
+ };
+
+ const isDisabled = isLoading || !isFormValid;
+
+ return (
+
+
+
+ {
+ // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite)
+ sprintf(_("Do you want to deregister %s?"), selectedProduct.name)
+ }
+
+
+
+ {_("Accept")}
+
+
+
+
+ );
+};
+
+/**
+ * Popup to show a warning when there is a registered product.
+ * @component
+ *
+ * @param {object} props
+ * @param {boolean} props.isOpen
+ * @param {function} props.onAccept - Callback to be called when the warning is accepted.
+ */
+const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => {
+ const { selectedProduct } = useProduct();
+
+ return (
+
+
+ {
+ sprintf(
+ // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite)
+ _("The product %s must be deregistered before selecting a new product."),
+ selectedProduct.name
+ )
+ }
+
{_("This product does not require registration.")}
}
+ />
+
+ );
+};
+
+/**
+ * Page for configuring a product.
+ * @component
+ */
+export default function ProductPage() {
+ const [managerStatus, setManagerStatus] = useState();
+ const [softwareStatus, setSoftwareStatus] = useState();
+ const { cancellablePromise } = useCancellablePromise();
+ const { manager, software } = useInstallerClient();
+
+ useEffect(() => {
+ cancellablePromise(manager.getStatus()).then(setManagerStatus);
+ return manager.onStatusChange(setManagerStatus);
+ }, [cancellablePromise, manager]);
+
+ useEffect(() => {
+ cancellablePromise(software.getStatus()).then(setSoftwareStatus);
+ return software.onStatusChange(setSoftwareStatus);
+ }, [cancellablePromise, software]);
+
+ const isLoading = managerStatus === BUSY || softwareStatus === BUSY;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx
new file mode 100644
index 0000000000..af2531340a
--- /dev/null
+++ b/web/src/components/product/ProductPage.test.jsx
@@ -0,0 +1,388 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 } from "@testing-library/react";
+
+import { BUSY } from "~/client/status";
+import { installerRender } from "~/test-utils";
+import { ProductPage } from "~/components/product";
+import { createClient } from "~/client";
+
+let mockManager;
+let mockSoftware;
+let mockProducts;
+let mockRegistration;
+
+const products = [
+ {
+ id: "Test-Product1",
+ name: "Test Product1",
+ description: "Test Product1 description"
+ },
+ {
+ id: "Test-Product2",
+ name: "Test Product2",
+ description: "Test Product2 description"
+ }
+];
+
+const selectedProduct = {
+ id: "Test-Product1",
+ name: "Test Product1",
+ description: "Test Product1 description"
+};
+
+jest.mock("~/client");
+
+jest.mock("~/context/product", () => ({
+ ...jest.requireActual("~/context/product"),
+ useProduct: () => ({ products: mockProducts, selectedProduct, registration: mockRegistration })
+}));
+
+beforeEach(() => {
+ mockManager = {
+ startProbing: jest.fn(),
+ getStatus: jest.fn().mockResolvedValue(),
+ onStatusChange: jest.fn()
+ };
+
+ mockSoftware = {
+ probe: jest.fn(),
+ getStatus: jest.fn().mockResolvedValue(),
+ onStatusChange: jest.fn(),
+ product: {
+ getSelected: selectedProduct.id,
+ select: jest.fn().mockResolvedValue(),
+ onChange: jest.fn()
+ }
+ };
+
+ mockProducts = products;
+
+ mockRegistration = {
+ requirement: "not-required",
+ code: null,
+ email: null
+ };
+
+ createClient.mockImplementation(() => (
+ {
+ manager: mockManager,
+ software: mockSoftware
+ }
+ ));
+});
+
+it("renders the product name and description", async () => {
+ installerRender();
+ await screen.findByText("Test Product1");
+ await screen.findByText("Test Product1 description");
+});
+
+it("shows a button to change the product", async () => {
+ installerRender();
+ await screen.findByRole("button", { name: "Change product" });
+});
+
+describe("if there is only a product", () => {
+ beforeEach(() => {
+ mockProducts = [products[0]];
+ });
+
+ it("does not show a button to change the product", async () => {
+ installerRender();
+ expect(screen.queryByRole("button", { name: "Change product" })).not.toBeInTheDocument();
+ });
+});
+
+describe("if the product is already registered", () => {
+ beforeEach(() => {
+ mockRegistration = {
+ requirement: "mandatory",
+ code: "111222",
+ email: "test@test.com"
+ };
+ });
+
+ it("shows the information about the registration", async () => {
+ installerRender();
+ await screen.findByText("**1222");
+ await screen.findByText("test@test.com");
+ });
+});
+
+describe("if the product does not require registration", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "not-required";
+ });
+
+ it("does not show a button to register the product", async () => {
+ installerRender();
+ expect(screen.queryByRole("button", { name: "Register" })).not.toBeInTheDocument();
+ });
+});
+
+describe("if the product requires registration", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "required";
+ });
+
+ describe("and the product is not registered yet", () => {
+ beforeEach(() => {
+ mockRegistration.code = null;
+ });
+
+ it("shows a button to register the product", async () => {
+ installerRender();
+ await screen.findByRole("button", { name: "Register" });
+ });
+ });
+
+ describe("and the product is already registered", () => {
+ beforeEach(() => {
+ mockRegistration.code = "11112222";
+ });
+
+ it("shows a button to deregister the product", async () => {
+ installerRender();
+ await screen.findByRole("button", { name: "Deregister product" });
+ });
+ });
+});
+
+describe("when the services are busy", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "required";
+ mockRegistration.code = null;
+ mockSoftware.getStatus = jest.fn().mockResolvedValue(BUSY);
+ });
+
+ it("shows disabled buttons", async () => {
+ installerRender();
+
+ const selectButton = await screen.findByRole("button", { name: "Change product" });
+ const registerButton = screen.getByRole("button", { name: "Register" });
+
+ expect(selectButton).toHaveAttribute("disabled");
+ expect(registerButton).toHaveAttribute("disabled");
+ });
+});
+
+describe("when the button for changing the product is clicked", () => {
+ describe("and the product is not registered", () => {
+ beforeEach(() => {
+ mockRegistration.code = null;
+ });
+
+ it("opens a popup for selecting a new product", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Change product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ within(popup).getByText("Choose a product");
+ within(popup).getByRole("radio", { name: "Test Product1" });
+ const radio = within(popup).getByRole("radio", { name: "Test Product2" });
+
+ await user.click(radio);
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ expect(mockSoftware.product.select).toHaveBeenCalledWith("Test-Product2");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ describe("if the popup is canceled", () => {
+ it("closes the popup without selecting a new product", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Change product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ const radio = within(popup).getByRole("radio", { name: "Test Product2" });
+
+ await user.click(radio);
+ const cancel = within(popup).getByRole("button", { name: "Cancel" });
+ await user.click(cancel);
+
+ expect(mockSoftware.product.select).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("and the product is registered", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "mandatory";
+ mockRegistration.code = "111222";
+ });
+
+ it("shows a warning", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Change product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ within(popup).getByText(/must be deregistered/);
+
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+});
+
+describe("when the button for registering the product is clicked", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "mandatory";
+ mockRegistration.code = null;
+ mockSoftware.product.register = jest.fn().mockResolvedValue({ success: true });
+ });
+
+ it("opens a popup for registering the product", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Register" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ within(popup).getByText("Register Test Product1");
+ const codeInput = within(popup).getByLabelText(/Registration code/);
+ const emailInput = within(popup).getByLabelText("Email");
+
+ await user.type(codeInput, "111222");
+ await user.type(emailInput, "test@test.com");
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ expect(mockSoftware.product.register).toHaveBeenCalledWith("111222", "test@test.com");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ describe("if the popup is canceled", () => {
+ it("closes the popup without registering the product", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Register" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ const cancel = within(popup).getByRole("button", { name: "Cancel" });
+ await user.click(cancel);
+
+ expect(mockSoftware.product.register).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("if there is an error registering the product", () => {
+ beforeEach(() => {
+ mockSoftware.product.register = jest.fn().mockResolvedValue({
+ success: false,
+ message: "Error registering product"
+ });
+ });
+
+ it("does not close the popup and shows the error", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Register" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ within(popup).getByText("Register Test Product1");
+ const codeInput = within(popup).getByLabelText(/Registration code/);
+
+ await user.type(codeInput, "111222");
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ within(popup).getByText("Error registering product");
+ });
+ });
+});
+
+describe("when the button to perform product de-registration is clicked", () => {
+ beforeEach(() => {
+ mockRegistration.requirement = "mandatory";
+ mockRegistration.code = "111222";
+ mockSoftware.product.deregister = jest.fn().mockResolvedValue({ success: true });
+ });
+
+ it("opens a popup to deregister the product", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Deregister product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ within(popup).getByText("Deregister Test Product1");
+
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ expect(mockSoftware.product.deregister).toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ describe("if the popup is canceled", () => {
+ it("closes the popup without performing product de-registration", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Deregister product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ const cancel = within(popup).getByRole("button", { name: "Cancel" });
+ await user.click(cancel);
+
+ expect(mockSoftware.product.deregister).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("if there is an error performing the product de-registration", () => {
+ beforeEach(() => {
+ mockSoftware.product.deregister = jest.fn().mockResolvedValue({
+ success: false,
+ message: "Product cannot be deregistered"
+ });
+ });
+
+ it("does not close the popup and shows the error", async () => {
+ const { user } = installerRender();
+
+ const button = screen.getByRole("button", { name: "Deregister product" });
+ await user.click(button);
+
+ const popup = await screen.findByRole("dialog");
+ const accept = within(popup).getByRole("button", { name: "Accept" });
+ await user.click(accept);
+
+ within(popup).getByText("Product cannot be deregistered");
+ });
+ });
+});
diff --git a/web/src/components/product/ProductRegistrationForm.jsx b/web/src/components/product/ProductRegistrationForm.jsx
new file mode 100644
index 0000000000..510f0c6c20
--- /dev/null
+++ b/web/src/components/product/ProductRegistrationForm.jsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 { Form, FormGroup } from "@patternfly/react-core";
+
+import { _ } from "~/i18n";
+import { EmailInput, PasswordInput } from "~/components/core";
+import { noop } from "~/utils";
+
+/**
+ * Form for registering a product.
+ * @component
+ *
+ * @param {object} props
+ * @param {boolean} props.id - Form id.
+ * @param {function} props.onSubmit - Callback to be called when the form is submitted.
+ * @param {(isValid: boolean) => void} props.onValidate - Callback to be called when the form is
+ * validated.
+ */
+export default function ProductRegistrationForm({
+ id,
+ onSubmit: onSubmitProp = noop,
+ onValidate = noop
+}) {
+ const [code, setCode] = useState("");
+ const [email, setEmail] = useState("");
+ const [isValidEmail, setIsValidEmail] = useState(true);
+
+ const onSubmit = (e) => {
+ e.preventDefault();
+ onSubmitProp({ code, email });
+ };
+
+ useEffect(() => {
+ const validate = () => {
+ return code.length > 0 && isValidEmail;
+ };
+
+ onValidate(validate());
+ }, [code, isValidEmail, onValidate]);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/product/ProductRegistrationForm.test.jsx b/web/src/components/product/ProductRegistrationForm.test.jsx
new file mode 100644
index 0000000000..14a9d18c4a
--- /dev/null
+++ b/web/src/components/product/ProductRegistrationForm.test.jsx
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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, { useState } from "react";
+import { Button } from "@patternfly/react-core";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import { ProductRegistrationForm } from "~/components/product";
+
+it("renders a field for entering the registration code", async() => {
+ plainRender();
+ await screen.findByLabelText(/Registration code/);
+});
+
+it("renders a field for entering an email", async() => {
+ plainRender();
+ await screen.findByLabelText("Email");
+});
+
+const ProductRegistrationFormTest = () => {
+ const [isSubmitted, setIsSubmitted] = useState(false);
+ const [isValid, setIsValid] = useState(true);
+
+ return (
+ <>
+
+
+ {isSubmitted &&
Form is submitted!
}
+ {isValid === false &&
Form is not valid!
}
+ >
+ );
+};
+
+it("triggers the onSubmit callback", async () => {
+ const { user } = plainRender();
+
+ expect(screen.queryByText("Form is submitted!")).toBeNull();
+
+ const button = screen.getByRole("button", { name: "Accept" });
+ await user.click(button);
+ await screen.findByText("Form is submitted!");
+});
+
+it("sets the form as invalid if there is no code", async () => {
+ plainRender();
+ await screen.findByText("Form is not valid!");
+});
+
+it("sets the form as invalid if there is a code and a wrong email", async () => {
+ const { user } = plainRender();
+ const codeInput = await screen.findByLabelText(/Registration code/);
+ const emailInput = await screen.findByLabelText("Email");
+ await user.type(codeInput, "111222");
+ await user.type(emailInput, "foo");
+
+ await screen.findByText("Form is not valid!");
+});
+
+it("does not set the form as invalid if there is a code and no email", async () => {
+ const { user } = plainRender();
+ const codeInput = await screen.findByLabelText(/Registration code/);
+ await user.type(codeInput, "111222");
+
+ expect(screen.queryByText("Form is not valid!")).toBeNull();
+});
+
+it("does not set the form as invalid if there is a code and a correct email", async () => {
+ const { user } = plainRender();
+ const codeInput = await screen.findByLabelText(/Registration code/);
+ const emailInput = await screen.findByLabelText("Email");
+ await user.type(codeInput, "111222");
+ await user.type(emailInput, "test@test.com");
+
+ expect(screen.queryByText("Form is not valid!")).toBeNull();
+});
diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx
similarity index 58%
rename from web/src/components/software/ProductSelectionPage.jsx
rename to web/src/components/product/ProductSelectionPage.jsx
index 86b8d622ef..585747a9db 100644
--- a/web/src/components/software/ProductSelectionPage.jsx
+++ b/web/src/components/product/ProductSelectionPage.jsx
@@ -21,47 +21,36 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { useInstallerClient } from "~/context/installer";
-import { useSoftware } from "~/context/software";
-import { _ } from "~/i18n";
-
-import {
- Button,
- Card,
- CardBody,
- Form,
- FormGroup,
- Radio
-} from "@patternfly/react-core";
+import { Button, Form, FormGroup } from "@patternfly/react-core";
+import { _ } from "~/i18n";
import { Icon, Loading } from "~/components/layout";
+import { ProductSelector } from "~/components/product";
import { Title, PageIcon, MainActions } from "~/components/layout/Layout";
+import { useInstallerClient } from "~/context/installer";
+import { useProduct } from "~/context/product";
function ProductSelectionPage() {
- const client = useInstallerClient();
+ const { manager, software } = useInstallerClient();
const navigate = useNavigate();
- const { products, selectedProduct } = useSoftware();
- const previous = selectedProduct?.id;
- const [selected, setSelected] = useState(selectedProduct?.id);
+ const { products, selectedProduct } = useProduct();
+ const [newProductId, setNewProductId] = useState(selectedProduct?.id);
useEffect(() => {
// TODO: display a notification in the UI to emphasizes that
// selected product has changed
- return client.software.onProductChange(() => navigate("/"));
- }, [client.software, navigate]);
-
- const isSelected = p => p.id === selected;
+ return software.product.onChange(() => navigate("/"));
+ }, [software, navigate]);
- const accept = async (e) => {
+ const onSubmit = async (e) => {
e.preventDefault();
- if (selected === previous) {
- navigate("/");
- return;
+
+ if (newProductId !== selectedProduct?.id) {
+ // TODO: handle errors
+ await software.product.select(newProductId);
+ manager.startProbing();
}
- // TODO: handle errors
- await client.software.selectProduct(selected);
- client.manager.startProbing();
navigate("/");
};
@@ -69,40 +58,20 @@ function ProductSelectionPage() {
);
- const buildOptions = () => {
- const options = products.map((p) => (
-
-
- setSelected(p.id)}
- />
-
-
- ));
-
- return options;
- };
-
return (
<>
{/* TRANSLATORS: page header */}
{_("Product selection")}
-
-
-
>
diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx
similarity index 83%
rename from web/src/components/software/ProductSelectionPage.test.jsx
rename to web/src/components/product/ProductSelectionPage.test.jsx
index 197e33cb27..89a943a16e 100644
--- a/web/src/components/software/ProductSelectionPage.test.jsx
+++ b/web/src/components/product/ProductSelectionPage.test.jsx
@@ -22,7 +22,7 @@
import React from "react";
import { screen } from "@testing-library/react";
import { installerRender, mockNavigateFn } from "~/test-utils";
-import { ProductSelectionPage } from "~/components/software";
+import { ProductSelectionPage } from "~/components/product";
import { createClient } from "~/client";
const products = [
@@ -39,9 +39,9 @@ const products = [
];
jest.mock("~/client");
-jest.mock("~/context/software", () => ({
- ...jest.requireActual("~/context/software"),
- useSoftware: () => {
+jest.mock("~/context/product", () => ({
+ ...jest.requireActual("~/context/product"),
+ useProduct: () => {
return {
products,
selectedProduct: products[0]
@@ -54,10 +54,12 @@ const managerMock = {
};
const softwareMock = {
- getProducts: () => Promise.resolve(products),
- getSelectedProduct: jest.fn(() => Promise.resolve(products[0])),
- selectProduct: jest.fn().mockResolvedValue(),
- onProductChange: jest.fn()
+ product: {
+ getAll: () => Promise.resolve(products),
+ getSelected: jest.fn(() => Promise.resolve(products[0])),
+ select: jest.fn().mockResolvedValue(),
+ onChange: jest.fn()
+ }
};
beforeEach(() => {
@@ -76,7 +78,7 @@ describe("when the user chooses a product", () => {
await user.click(radio);
const button = await screen.findByRole("button", { name: "Select" });
await user.click(button);
- expect(softwareMock.selectProduct).toHaveBeenCalledWith("MicroOS");
+ expect(softwareMock.product.select).toHaveBeenCalledWith("MicroOS");
expect(managerMock.startProbing).toHaveBeenCalled();
expect(mockNavigateFn).toHaveBeenCalledWith("/");
});
@@ -88,7 +90,7 @@ describe("when the user chooses does not change the product", () => {
await screen.findByText("openSUSE Tumbleweed");
const button = await screen.findByRole("button", { name: "Select" });
await user.click(button);
- expect(softwareMock.selectProduct).not.toHaveBeenCalled();
+ expect(softwareMock.product.select).not.toHaveBeenCalled();
expect(managerMock.startProbing).not.toHaveBeenCalled();
expect(mockNavigateFn).toHaveBeenCalledWith("/");
});
diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx
new file mode 100644
index 0000000000..311a6a7e89
--- /dev/null
+++ b/web/src/components/product/ProductSelector.jsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 { Card, CardBody, Radio } from "@patternfly/react-core";
+
+import { _ } from "~/i18n";
+import { noop } from "~/utils";
+
+export default function ProductSelector({ value, products = [], onChange = noop }) {
+ if (products.length === 0) return
{_("No products available for selection")}
;
+
+ const isSelected = (product) => product.id === value;
+
+ return (
+ products.map((p) => (
+
+
+ onChange(p.id)}
+ />
+
+
+ ))
+ );
+}
diff --git a/web/src/components/product/ProductSelector.test.jsx b/web/src/components/product/ProductSelector.test.jsx
new file mode 100644
index 0000000000..b40af415a0
--- /dev/null
+++ b/web/src/components/product/ProductSelector.test.jsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 { ProductSelector } from "~/components/product";
+import { createClient } from "~/client";
+
+jest.mock("~/client");
+
+const products = [
+ {
+ id: "ALP-Dolomite",
+ name: "ALP Dolomite",
+ description: "ALP Dolomite description"
+ },
+ {
+ id: "Tumbleweed",
+ name: "openSUSE Tumbleweed",
+ description: "Tumbleweed description..."
+ },
+ {
+ id: "MicroOS",
+ name: "openSUSE MicroOS",
+ description: "MicroOS description"
+ }
+];
+
+beforeEach(() => {
+ createClient.mockImplementation(() => ({}));
+});
+
+it("shows an option for each product", async () => {
+ installerRender();
+ await screen.findByRole("radio", { name: "ALP Dolomite" });
+ await screen.findByRole("radio", { name: "openSUSE Tumbleweed" });
+ await screen.findByRole("radio", { name: "openSUSE MicroOS" });
+});
+
+it("selects the given value", async () => {
+ installerRender();
+ await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true });
+});
+
+it("calls onChange if a new option is clicked", async () => {
+ const onChangeFn = jest.fn();
+ const { user } = installerRender();
+ const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" });
+ await user.click(radio);
+ expect(onChangeFn).toHaveBeenCalledWith("Tumbleweed");
+});
+
+it("shows a message if there is no product for selection", async () => {
+ installerRender();
+ await screen.findByText(/no products available/i);
+});
diff --git a/web/src/components/software/ChangeProductLink.jsx b/web/src/components/product/index.js
similarity index 60%
rename from web/src/components/software/ChangeProductLink.jsx
rename to web/src/components/product/index.js
index 8dc6f07001..be115a18c8 100644
--- a/web/src/components/software/ChangeProductLink.jsx
+++ b/web/src/components/product/index.js
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2023] SUSE LLC
+ * Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
@@ -19,21 +19,7 @@
* find current contact information at www.suse.com.
*/
-import React from "react";
-import { Link } from "react-router-dom";
-import { useSoftware } from "~/context/software";
-import { Icon } from "~/components/layout";
-import { _ } from "~/i18n";
-
-export default function ChangeProductLink() {
- const { products } = useSoftware();
-
- if (products?.length === 1) return null;
-
- return (
-
-
- {_("Change product")}
-
- );
-}
+export { default as ProductPage } from "./ProductPage";
+export { default as ProductRegistrationForm } from "./ProductRegistrationForm";
+export { default as ProductSelectionPage } from "./ProductSelectionPage";
+export { default as ProductSelector } from "./ProductSelector";
diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx
deleted file mode 100644
index c89a62222a..0000000000
--- a/web/src/components/software/ChangeProductLink.test.jsx
+++ /dev/null
@@ -1,81 +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 version 2 of the GNU General Public License as published
- * by the Free Software Foundation.
- *
- * 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 } from "~/test-utils";
-import { createClient } from "~/client";
-import { ChangeProductLink } from "~/components/software";
-
-let mockProducts;
-
-jest.mock("~/client");
-jest.mock("~/context/software", () => ({
- ...jest.requireActual("~/context/software"),
- useSoftware: () => {
- return {
- products: mockProducts,
- };
- }
-}));
-
-beforeEach(() => {
- createClient.mockImplementation(() => {
- return {
- software: {
- onProductChange: jest.fn()
- },
- };
- });
-});
-
-describe("ChangeProductLink", () => {
- describe("when there is only a single product", () => {
- beforeEach(() => {
- mockProducts = [
- { id: "openSUSE", name: "openSUSE Tumbleweed" }
- ];
- });
-
- it("renders nothing", async () => {
- installerRender();
-
- const main = await screen.findByRole("main");
- await waitFor(() => expect(main).toBeEmptyDOMElement());
- });
- });
-
- describe("when there is more than one product", () => {
- beforeEach(() => {
- mockProducts = [
- { id: "openSUSE", name: "openSUSE Tumbleweed" },
- { id: "Leap Micro", name: "openSUSE Micro" }
- ];
- });
-
- it("renders a link for navigating to the selection product page", async () => {
- installerRender();
- const link = await screen.findByRole("link", { name: "Change product" });
-
- expect(link).toHaveAttribute("href", "/products");
- });
- });
-});
diff --git a/web/src/components/software/PatternSelector.jsx b/web/src/components/software/PatternSelector.jsx
index d4886c5cd0..30b550a947 100644
--- a/web/src/components/software/PatternSelector.jsx
+++ b/web/src/components/software/PatternSelector.jsx
@@ -27,6 +27,7 @@ import { useInstallerClient } from "~/context/installer";
import { Section, ValidationErrors } from "~/components/core";
import PatternGroup from "./PatternGroup";
import PatternItem from "./PatternItem";
+import { toValidationError } from "~/utils";
import UsedSize from "./UsedSize";
import { _ } from "~/i18n";
@@ -153,7 +154,7 @@ function PatternSelector() {
const refresh = async () => {
setSelected(await client.software.selectedPatterns());
setUsed(await client.software.getUsedSpace());
- setErrors(await client.software.getValidationErrors());
+ setErrors(await client.software.getIssues());
};
refresh();
@@ -166,7 +167,7 @@ function PatternSelector() {
const loadData = async () => {
setSelected(await client.software.selectedPatterns());
setUsed(await client.software.getUsedSpace());
- setErrors(await client.software.getValidationErrors());
+ setErrors(await client.software.getIssues());
setPatterns(await client.software.patterns(true));
};
@@ -205,11 +206,15 @@ function PatternSelector() {
// if there is just a single error then the error is displayed directly instead of this summary
const errorLabel = sprintf(_("%d errors"), errors.length);
+ // FIXME: ValidationErrors should be replaced by an equivalent component to show issues.
+ // Note that only the Users client uses the old Validation D-Bus interface.
+ const validationErrors = errors.map(toValidationError);
+
return (
<>
-
+ {
@@ -40,7 +40,7 @@ beforeEach(() => {
software: {
selectedPatterns: selectedPatternsFn,
getUsedSpace: getUsedSpaceFn,
- getValidationErrors: getValidationErrorsFn,
+ getIssues: getIssuesFn,
patterns: patternsFn
},
};
diff --git a/web/src/components/software/index.js b/web/src/components/software/index.js
index b2c6fef467..af42a2eb9d 100644
--- a/web/src/components/software/index.js
+++ b/web/src/components/software/index.js
@@ -19,8 +19,6 @@
* find current contact information at www.suse.com.
*/
-export { default as ProductSelectionPage } from "./ProductSelectionPage";
-export { default as ChangeProductLink } from "./ChangeProductLink";
export { default as PatternSelector } from "./PatternSelector";
export { default as UsedSize } from "./UsedSize";
export { default as SoftwarePage } from "./SoftwarePage";
diff --git a/web/src/context/agama.jsx b/web/src/context/agama.jsx
index 6306c1ba8c..0aa16e811b 100644
--- a/web/src/context/agama.jsx
+++ b/web/src/context/agama.jsx
@@ -24,7 +24,7 @@
import React from "react";
import { InstallerClientProvider } from "./installer";
import { L10nProvider } from "./l10n";
-import { SoftwareProvider } from "./software";
+import { ProductProvider } from "./product";
import { NotificationProvider } from "./notification";
/**
@@ -37,11 +37,11 @@ function AgamaProviders({ children }) {
return (
-
+
{children}
-
+
);
diff --git a/web/src/context/notification.jsx b/web/src/context/notification.jsx
index 38446dada1..d04df31486 100644
--- a/web/src/context/notification.jsx
+++ b/web/src/context/notification.jsx
@@ -37,7 +37,8 @@ function NotificationProvider({ children }) {
const load = useCallback(async () => {
if (!client) return;
- const hasIssues = await cancellablePromise(client.issues.any());
+ const issues = await cancellablePromise(client.issues());
+ const hasIssues = Object.values(issues).flat().length > 0;
update({ issues: hasIssues });
}, [client, cancellablePromise, update]);
@@ -45,7 +46,7 @@ function NotificationProvider({ children }) {
if (!client) return;
load();
- return client.issues.onIssuesChange(load);
+ return client.onIssuesChange(load);
}, [client, load]);
const value = [state, update];
diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx
new file mode 100644
index 0000000000..4f476f710a
--- /dev/null
+++ b/web/src/context/product.jsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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, { useContext, useEffect, useState } from "react";
+import { useCancellablePromise } from "~/utils";
+import { useInstallerClient } from "./installer";
+
+const ProductContext = React.createContext([]);
+
+function ProductProvider({ children }) {
+ const client = useInstallerClient();
+ const { cancellablePromise } = useCancellablePromise();
+ const [products, setProducts] = useState(undefined);
+ const [selectedId, setSelectedId] = useState(undefined);
+ const [registration, setRegistration] = useState(undefined);
+
+ useEffect(() => {
+ const load = async () => {
+ const productManager = client.software.product;
+ const available = await cancellablePromise(productManager.getAll());
+ const selected = await cancellablePromise(productManager.getSelected());
+ const registration = await cancellablePromise(productManager.getRegistration());
+ setProducts(available);
+ setSelectedId(selected?.id || null);
+ setRegistration(registration);
+ };
+
+ if (client) {
+ load().catch(console.error);
+ }
+ }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]);
+
+ useEffect(() => {
+ if (!client) return;
+
+ return client.software.product.onChange(setSelectedId);
+ }, [client, setSelectedId]);
+
+ useEffect(() => {
+ if (!client) return;
+
+ return client.software.product.onRegistrationChange(setRegistration);
+ }, [client, setRegistration]);
+
+ const value = { products, selectedId, registration };
+ return {children};
+}
+
+function useProduct() {
+ const context = useContext(ProductContext);
+
+ if (!context) {
+ throw new Error("useProduct must be used within a ProductProvider");
+ }
+
+ const { products = [], selectedId } = context;
+ const selectedProduct = products.find(p => p.id === selectedId) || null;
+
+ return { ...context, selectedProduct };
+}
+
+export { ProductProvider, useProduct };
diff --git a/web/src/context/software.jsx b/web/src/context/software.jsx
deleted file mode 100644
index 8e53f91405..0000000000
--- a/web/src/context/software.jsx
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) [2022] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of version 2 of the GNU General Public License as published
- * by the Free Software Foundation.
- *
- * 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 } from "react";
-import { useCancellablePromise } from "~/utils";
-import { useInstallerClient } from "./installer";
-
-const SoftwareContext = React.createContext([]);
-
-function SoftwareProvider({ children }) {
- const client = useInstallerClient();
- const { cancellablePromise } = useCancellablePromise();
- const [products, setProducts] = React.useState(undefined);
- const [selectedId, setSelectedId] = React.useState(undefined);
-
- useEffect(() => {
- const loadProducts = async () => {
- const available = await cancellablePromise(client.software.getProducts());
- const selected = await cancellablePromise(client.software.getSelectedProduct());
- setProducts(available);
- setSelectedId(selected?.id || null);
- };
-
- if (client) {
- loadProducts().catch(console.error);
- }
- }, [client, setProducts, setSelectedId, cancellablePromise]);
-
- useEffect(() => {
- if (!client) return;
-
- return client.software.onProductChange(setSelectedId);
- }, [client, setSelectedId]);
-
- const value = [products, selectedId];
- return {children};
-}
-
-function useSoftware() {
- const context = React.useContext(SoftwareContext);
-
- if (!context) {
- throw new Error("useSoftware must be used within a SoftwareProvider");
- }
-
- const [products, selectedId] = context;
-
- let selectedProduct = selectedId;
- if (selectedId && products) {
- selectedProduct = products.find(p => p.id === selectedId) || null;
- }
-
- return {
- products,
- selectedProduct
- };
-}
-
-export { SoftwareProvider, useSoftware };
diff --git a/web/src/index.js b/web/src/index.js
index 3b7b6815f3..53d58610a3 100644
--- a/web/src/index.js
+++ b/web/src/index.js
@@ -37,7 +37,8 @@ import App from "~/App";
import Main from "~/Main";
import DevServerWrapper from "~/DevServerWrapper";
import { Overview } from "~/components/overview";
-import { ProductSelectionPage, SoftwarePage } from "~/components/software";
+import { ProductPage, ProductSelectionPage } from "~/components/product";
+import { SoftwarePage } from "~/components/software";
import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage";
import { UsersPage } from "~/components/users";
import { L10nPage } from "~/components/l10n";
@@ -76,6 +77,7 @@ root.render(
}>
} />
} />
+ } />
} />
} />
} />