diff --git a/cli/lib/dinstaller_cli/clients.rb b/cli/lib/dinstaller_cli/clients.rb index cd3e82ded4..83ae6296c6 100644 --- a/cli/lib/dinstaller_cli/clients.rb +++ b/cli/lib/dinstaller_cli/clients.rb @@ -25,5 +25,4 @@ module Clients end end -require "dinstaller_cli/clients/language" require "dinstaller_cli/clients/storage" diff --git a/cli/test/dinstaller_cli/clients_test.rb b/cli/test/dinstaller_cli/clients_test.rb new file mode 100644 index 0000000000..adf936d392 --- /dev/null +++ b/cli/test/dinstaller_cli/clients_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# 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. + +require_relative "../test_helper" +require "dinstaller_cli/clients" + +# nothing else, this test just covers the require-only files diff --git a/cli/test/dinstaller_cli_test.rb b/cli/test/dinstaller_cli_test.rb new file mode 100644 index 0000000000..3e0580b009 --- /dev/null +++ b/cli/test/dinstaller_cli_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# 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. + +require_relative "test_helper" +require "dinstaller_cli" + +# nothing else, this test just covers the require-only files diff --git a/service/.editorconfig b/service/.editorconfig new file mode 100644 index 0000000000..fd2b9ed67d --- /dev/null +++ b/service/.editorconfig @@ -0,0 +1,9 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# 2 space indentation +[*.rb] +indent_style = space +indent_size = 2 diff --git a/service/lib/dinstaller/can_ask_question.rb b/service/lib/dinstaller/can_ask_question.rb index e14acb30fc..38b94952f2 100644 --- a/service/lib/dinstaller/can_ask_question.rb +++ b/service/lib/dinstaller/can_ask_question.rb @@ -24,7 +24,7 @@ module DInstaller module CanAskQuestion # @!method questions_manager # @note Classes including this mixin must define a #questions_manager method - # @return [QuestionsManager] + # @return [QuestionsManager,DBus::Clients::QuestionsManager] # Asks the given question and waits until the question is answered # @@ -33,14 +33,16 @@ module CanAskQuestion # ask(question2) { |q| q.answer == :yes } #=> Boolean # # @param question [Question] - # @yield [Question] Gives the answered question to the block. + # @yield [Question,DBus::Clients::Question] Gives the answered question to the block. # @return [Symbol, Object] The question answer, or the result of the block in case a block is # given. def ask(question) - questions_manager.add(question) - questions_manager.wait - result = block_given? ? yield(question) : question.answer - questions_manager.delete(question) + # asked_question has the same interface as question + # but it may be a D-Bus proxy, if our questions_manager is also one + asked_question = questions_manager.add(question) + questions_manager.wait([asked_question]) + result = block_given? ? yield(asked_question) : asked_question.answer + questions_manager.delete(asked_question) result end diff --git a/service/lib/dinstaller/dbus/clients/question.rb b/service/lib/dinstaller/dbus/clients/question.rb new file mode 100644 index 0000000000..02d4ecfe59 --- /dev/null +++ b/service/lib/dinstaller/dbus/clients/question.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# 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. + +require "dinstaller/dbus/clients/base" + +module DInstaller + module DBus + module Clients + # D-Bus client for asking a question. + # Its interface is a subset of {DInstaller::Question} + # so it can be used in the block of {DInstaller::CanAskQuestion#ask}. + class Question < Base + # @return [::DBus::ProxyObject] + attr_reader :dbus_object + + # @param [::DBus::ObjectPath] object_path + def initialize(object_path) + super() + + @dbus_object = service[object_path] + @dbus_iface = @dbus_object["org.opensuse.DInstaller.Question1"] + end + + # @return [String] + def service_name + @service_name ||= "org.opensuse.DInstaller" + end + + # TODO: what other methods are useful? + + # @return [Symbol] no answer yet = :"" + def answer + @dbus_iface["Answer"].to_sym + end + + # Whether the question is already answered + # + # @return [Boolean] + def answered? + answer != :"" + end + end + end + end +end diff --git a/service/lib/dinstaller/dbus/clients/questions_manager.rb b/service/lib/dinstaller/dbus/clients/questions_manager.rb new file mode 100644 index 0000000000..a03d4d6511 --- /dev/null +++ b/service/lib/dinstaller/dbus/clients/questions_manager.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# 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. + +require "dinstaller/dbus/clients/base" +require "dinstaller/dbus/clients/question" + +module DInstaller + module DBus + module Clients + # D-Bus client for asking a question. + # It has the same interface as {DInstaller::QuestionsManager} + # so it can be used for {DInstaller::CanAskQuestion}. + class QuestionsManager < Base + def initialize + super + + @dbus_object = service["/org/opensuse/DInstaller/Questions1"] + @dbus_object.default_iface = "org.opensuse.DInstaller.Questions1" + end + + # @return [String] + def service_name + @service_name ||= "org.opensuse.DInstaller" + end + + # Adds a question + # + # @param question [DInstaller::Question] + # @return [DBus::Clients::Question] + def add(question) + q_path = @dbus_object.New( + question.text, + question.options.map(&:to_s), + Array(question.default_option&.to_s) + ) + DBus::Clients::Question.new(q_path) + end + + # Deletes the given question + # + # @param question [DBus::Clients::Question] + # @return [void] + # @raise [::DBus::Error] if trying to delete a question twice + def delete(question) + @dbus_object.Delete(question.dbus_object.path) + end + + # Waits until specified questions are answered. + # @param questions [Array] + # @return [void] + def wait(questions) + # TODO: detect if no UI showed up to display the questions and time out? + # for example: + # (0..Float::INFINITY).each { |i| break if i > 100 && !question.displayed; ... } + + # We should register the InterfacesAdded callback... BEFORE adding to avoid races. + # Stupid but simple way: poll the answer property, sleep, repeat + loop do + questions = questions.find_all { |q| !q.answered? } + break if questions.empty? + + sleep(0.5) + end + end + + private + + # @return [::DBus::Object] + attr_reader :dbus_object + end + end + end +end diff --git a/service/lib/dinstaller/dbus/question.rb b/service/lib/dinstaller/dbus/question.rb index 7a565bec17..3755a3bc8c 100644 --- a/service/lib/dinstaller/dbus/question.rb +++ b/service/lib/dinstaller/dbus/question.rb @@ -162,11 +162,11 @@ def initialize(path, backend, logger) add_interfaces end - private - # @return [DInstaller::Question] attr_reader :backend + private + # Adds interfaces to the question def add_interfaces INTERFACES_TO_INCLUDE[backend.class].each { |i| singleton_class.include(i) } diff --git a/service/lib/dinstaller/dbus/questions.rb b/service/lib/dinstaller/dbus/questions.rb index 7e0fe01438..835d208a60 100644 --- a/service/lib/dinstaller/dbus/questions.rb +++ b/service/lib/dinstaller/dbus/questions.rb @@ -37,6 +37,9 @@ class Questions < ::DBus::Object PATH = "/org/opensuse/DInstaller/Questions1" private_constant :PATH + QUESTIONS_INTERFACE = "org.opensuse.DInstaller.Questions1" + private_constant :QUESTIONS_INTERFACE + OBJECT_MANAGER_INTERFACE = "org.freedesktop.DBus.ObjectManager" private_constant :OBJECT_MANAGER_INTERFACE @@ -73,6 +76,31 @@ def managed_objects dbus_signal(:InterfacesRemoved, "object:o, interfaces:as") end + dbus_interface QUESTIONS_INTERFACE do + # default_option is an array of 0 or 1 elements + dbus_method :New, "in text:s, in options:as, in default_option:as, out q:o" do + |text, options, default_option| + + backend_q = DInstaller::Question.new( + text, + options: options.map(&:to_sym), + default_option: default_option.map(&:to_sym).first + ) + backend.add(backend_q) + path_for(backend_q) + end + + dbus_method :Delete, "in question:o" do |question_path| + dbus_q = @service.get_node(question_path)&.object + raise ArgumentError, "Object path #{question_path} not found" unless dbus_q + raise ArgumentError, "Object #{question_path} is not a Question" unless + dbus_q.is_a? DInstaller::DBus::Question + + backend_q = dbus_q.backend + backend.delete(backend_q) + end + end + private # @return [DInstaller::QuestionsManager] diff --git a/service/lib/dinstaller/manager.rb b/service/lib/dinstaller/manager.rb index 61e9b4d37e..5975337140 100644 --- a/service/lib/dinstaller/manager.rb +++ b/service/lib/dinstaller/manager.rb @@ -22,6 +22,7 @@ require "yast" require "bootloader/proposal_client" require "bootloader/finish_client" +require "dinstaller/can_ask_question" require "dinstaller/config" require "dinstaller/network" require "dinstaller/security" @@ -45,6 +46,7 @@ module DInstaller # other services via D-Bus (e.g., `org.opensuse.DInstaller.Software`). class Manager include WithProgress + include CanAskQuestion # @return [Logger] attr_reader :logger diff --git a/service/lib/dinstaller/questions_manager.rb b/service/lib/dinstaller/questions_manager.rb index 1e646407b1..a6a8802ece 100644 --- a/service/lib/dinstaller/questions_manager.rb +++ b/service/lib/dinstaller/questions_manager.rb @@ -46,14 +46,14 @@ def initialize(logger) # @yieldparam question [Question] added question # # @param question [Question] - # @return [Boolean] whether the question was added + # @return [Question,nil] the actually added question (to be passed to {#delete} later) def add(question) - return false if include?(question) + return nil if include?(question) questions << question on_add_callbacks.each { |c| c.call(question) } - true + question end # Deletes the given question @@ -63,26 +63,30 @@ def add(question) # @yieldparam question [Question] deleted question # # @param question [Question] - # @return [Boolean] whether the question was deleted + # @return [Question,nil] whether the question was deleted def delete(question) - return false unless include?(question) + return nil unless include?(question) questions.delete(question) on_delete_callbacks.each { |c| c.call(question) } - true + question end - # Waits until all questions are answered + # Waits until all specified questions are answered. + # There may be other questions, asked from other services, which + # are waited for by remote question managers, so we ignore those. # # Callbacks are periodically called while waiting, see {#on_wait}. - def wait + # @param questions [Array] + def wait(questions) logger.info "Waiting for questions to be answered" loop do on_wait_callbacks.each(&:call) + break if questions.all?(&:answered?) + sleep(0.1) - break if questions_answered? end end @@ -134,12 +138,5 @@ def on_wait(&block) def include?(question) questions.any? { |q| q.id == question.id } end - - # Whether all questions are already answered - # - # @return [Boolean] - def questions_answered? - questions.all?(&:answered?) - end end end diff --git a/service/test/dinstaller/dbus/clients/question_test.rb b/service/test/dinstaller/dbus/clients/question_test.rb new file mode 100644 index 0000000000..b7cd7b68c9 --- /dev/null +++ b/service/test/dinstaller/dbus/clients/question_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# 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. + +require_relative "../../../test_helper" +require "dinstaller/dbus/clients/question" +require "dbus" + +describe DInstaller::DBus::Clients::Question do + before do + allow(::DBus::SystemBus).to receive(:instance).and_return(bus) + allow(bus).to receive(:service).with("org.opensuse.DInstaller").and_return(service) + allow(service).to receive(:[]).with("/org/opensuse/DInstaller/Questions1/23") + .and_return(dbus_object) + allow(dbus_object).to receive(:[]).with("org.opensuse.DInstaller.Question1") + .and_return(question_iface) + end + + let(:bus) { instance_double(::DBus::SystemBus) } + let(:service) { instance_double(::DBus::Service) } + let(:dbus_object) { instance_double(::DBus::ProxyObject) } + let(:question_iface) { instance_double(::DBus::ProxyObjectInterface) } + + subject { described_class.new("/org/opensuse/DInstaller/Questions1/23") } + + describe "#answered?" do + it "returns false if there is no answer" do + expect(question_iface).to receive(:[]).with("Answer").and_return("") + expect(subject.answered?).to eq false + end + end +end diff --git a/service/test/dinstaller/dbus/clients/questions_manager_test.rb b/service/test/dinstaller/dbus/clients/questions_manager_test.rb new file mode 100644 index 0000000000..ca0a6cf666 --- /dev/null +++ b/service/test/dinstaller/dbus/clients/questions_manager_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# 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. + +require_relative "../../../test_helper" +require "dinstaller/dbus/clients/questions_manager" +require "dinstaller/question" +require "dbus" + +describe DInstaller::DBus::Clients::QuestionsManager do + before do + allow(::DBus::SystemBus).to receive(:instance).and_return(bus) + allow(bus).to receive(:service).with("org.opensuse.DInstaller").and_return(service) + allow(service).to receive(:[]).with("/org/opensuse/DInstaller/Questions1") + .and_return(dbus_object) + allow(dbus_object).to receive(:default_iface=) + end + + let(:bus) { instance_double(::DBus::SystemBus) } + let(:service) { instance_double(::DBus::Service) } + let(:dbus_object) { instance_double(::DBus::ProxyObject) } + let(:properties_iface) { instance_double(::DBus::ProxyObjectInterface) } + + let(:question1) { DInstaller::Question.new("What?", options: [:this, :that]) } + let(:question2) do + DInstaller::Question.new("When?", options: [:now, :later], default_option: :now) + end + let(:question1_proxy) do + instance_double(::DBus::ProxyObject, path: "/org/opensuse/DInstaller/Questions1/33") + end + let(:question1_stub) do + instance_double(DInstaller::DBus::Clients::Question, dbus_object: question1_proxy) + end + + describe "#add" do + # Using partial double because methods are dynamically added to the proxy object + let(:dbus_object) { double(::DBus::ProxyObject) } + + it "asks the service to add a question and returns a stub object for it" do + expect(dbus_object).to receive(:New).with("What?", ["this", "that"], []) + expect(DInstaller::DBus::Clients::Question).to receive(:new).and_return(question1_stub) + expect(subject.add(question1)).to eq question1_stub + end + end + + describe "#delete" do + # Using partial double because methods are dynamically added to the proxy object + let(:dbus_object) { double(::DBus::ProxyObject) } + + it "asks the service to delete the question" do + expect(dbus_object).to receive(:Delete).with(question1_proxy.path) + expect { subject.delete(question1_stub) }.to_not raise_error + end + + it "propagates errors" do + # let's say we mistakenly try to delete the same Q twice + error = DBus::Error.new("Oopsie") + allow(dbus_object).to receive(:Delete).and_raise(error) + expect { subject.delete(question1_stub) }.to raise_error(DBus::Error) + end + end + + describe "#wait" do + it "loops and sleeps until all specified questions are answered" do + expect(question1).to receive(:answered?).and_return(true) + expect(question2).to receive(:answered?).and_return(false, true) + + expect(subject).to receive(:sleep).exactly(1).times + subject.wait([question1, question2]) + end + end +end diff --git a/service/test/dinstaller/dbus/questions_test.rb b/service/test/dinstaller/dbus/questions_test.rb index 5ba7f1d7fb..6e03274c53 100644 --- a/service/test/dinstaller/dbus/questions_test.rb +++ b/service/test/dinstaller/dbus/questions_test.rb @@ -90,7 +90,8 @@ expect(system_bus).to receive(:dispatch_message_queue) - backend.wait + question1 = DInstaller::Question.new("test1") + backend.wait([question1]) end describe "#managed_objects" do diff --git a/service/test/dinstaller/manager_test.rb b/service/test/dinstaller/manager_test.rb index 6eca5dd8b7..d2f6025339 100644 --- a/service/test/dinstaller/manager_test.rb +++ b/service/test/dinstaller/manager_test.rb @@ -22,7 +22,9 @@ require_relative "../test_helper" require "dinstaller/manager" require "dinstaller/config" +require "dinstaller/question" require "dinstaller/dbus/service_status" +require "dinstaller/dbus/clients/questions_manager" describe DInstaller::Manager do subject { described_class.new(config, logger) } @@ -183,4 +185,21 @@ subject.select_product("Leap") end end + + describe "#testing_question" do + let(:question_stub) { instance_double(DInstaller::DBus::Clients::Question, answer: :blue) } + + # this is a clumsy way to test the CanAskQuestion mixin + it "uses CanAskQuestion#ask" do + expect(questions_manager).to receive(:add).and_return(question_stub) + expect(questions_manager).to receive(:wait) + expect(questions_manager).to receive(:delete) + + question = DInstaller::Question.new("What is your favorite color?", options: [:blue, :yellow]) + correct = subject.ask(question) do |q| + q.answer == :blue + end + expect(correct).to be true + end + end end diff --git a/service/test/dinstaller/questions_manager_test.rb b/service/test/dinstaller/questions_manager_test.rb index 2895343810..55a1c69de3 100644 --- a/service/test/dinstaller/questions_manager_test.rb +++ b/service/test/dinstaller/questions_manager_test.rb @@ -51,8 +51,8 @@ subject.add(question1) end - it "returns true" do - expect(subject.add(question1)).to eq(true) + it "returns trthy value" do + expect(subject.add(question1)).to be_truthy end end @@ -73,8 +73,8 @@ subject.add(question1) end - it "returns false" do - expect(subject.add(question1)).to eq(false) + it "returns falsy value" do + expect(subject.add(question1)).to be_falsy end end end @@ -101,8 +101,8 @@ subject.delete(question1) end - it "returns true" do - expect(subject.delete(question1)).to eq(true) + it "returns truthy value" do + expect(subject.delete(question1)).to be_truthy end end @@ -123,8 +123,8 @@ subject.delete(question1) end - it "returns false" do - expect(subject.delete(question1)).to eq(false) + it "returns falsy value" do + expect(subject.delete(question1)).to be_falsy end end end @@ -151,15 +151,15 @@ end it "waits until all questions are answered" do - expect(subject).to receive(:sleep).exactly(3).times + expect(subject).to receive(:sleep).exactly(2).times - subject.wait + subject.wait([question1, question2]) end it "calls the #on_wait callbacks while waiting" do expect(callback).to receive(:call).and_call_original.exactly(3).times - subject.wait + subject.wait([question1, question2]) end end end