chromium/chrome/browser/speech/extension_api/tts_extension_api_lacros_browsertest.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <vector>

#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/task/single_thread_task_runner.h"
#include "base/test/run_until.h"
#include "base/test/test_future.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/speech/extension_api/tts_engine_extension_api.h"
#include "chrome/browser/speech/tts_crosapi_util.h"
#include "chrome/browser/speech/tts_lacros.h"
#include "chromeos/crosapi/mojom/test_controller.mojom.h"
#include "chromeos/crosapi/mojom/tts.mojom.h"
#include "chromeos/lacros/lacros_service.h"
#include "chromeos/lacros/lacros_test_helper.h"
#include "content/public/browser/tts_controller.h"
#include "content/public/browser/tts_platform.h"
#include "content/public/test/browser_test.h"
#include "extensions/browser/test_extension_registry_observer.h"

namespace extensions {

namespace {

// TODO(crbug.com/40259646): Deprecate the version skew handling code once the
// stable channel passes beyond 116.0.5817.0.
bool DoesAshSupportLacrosTtsFeatureFlagForTest() {
  // Make sure Ash is in the version that enables Lacros Tts support code
  // only by its the associated feature.
  // Note: Before version 116.0.5817.0, there was a test workaround that allows
  // Ash to enable the Lacros Tts support code without the feature flag
  // being enabled.
  return chromeos::IsAshVersionAtLeastForTesting(base::Version({116, 0, 5817}));
}

}  // namespace

class LacrosTtsApiTest : public ExtensionApiTest,
                         public content::VoicesChangedDelegate {
 public:
  void SetUp() override {
    // Start unique Ash instance with Lacros Tts Support feature enabled.
    StartUniqueAshChrome({}, {"DisableLacrosTtsSupport"}, {},
                         "crbug/1451677 Switch to shared ash when lacros tts "
                         "support is enabled by default");
    ExtensionApiTest::SetUp();
  }

  void SetUpInProcessBrowserTestFixture() override {
    ExtensionApiTest::SetUpInProcessBrowserTestFixture();

    ASSERT_TRUE(tts_crosapi_util::ShouldEnableLacrosTtsSupport());
    content::TtsController::SkipAddNetworkChangeObserverForTests(true);
    content::TtsController* tts_controller =
        content::TtsController::GetInstance();
    tts_controller->SetTtsEngineDelegate(TtsExtensionEngine::GetInstance());
  }

 protected:
  bool HasVoiceWithName(const std::string& name) {
    std::vector<content::VoiceData> voices;
    content::TtsController::GetInstance()->GetVoices(profile(), GURL(),
                                                     &voices);
    for (auto& voice : voices) {
      if (voice.name == name) {
        return true;
      }
    }

    return false;
  }

  // content::VoicesChangedDelegate:
  void OnVoicesChanged() override {
    voices_changed_ = true;
    std::vector<content::VoiceData> voices;
    content::TtsController::GetInstance()->GetVoices(profile(), GURL(),
                                                     &voices);
  }

  bool VoicesChangedNotified() { return voices_changed_; }
  void ResetVoicesChanged() { voices_changed_ = false; }

  void WaitUntilVoicesLoaded(const std::string& voice_name) {
    ASSERT_TRUE(
        base::test::RunUntil([&] { return HasVoiceWithName(voice_name); }));
  }

  void WaitUntilVoicesUnloaded(const std::string& voice_name) {
    ASSERT_TRUE(
        base::test::RunUntil([&] { return !HasVoiceWithName(voice_name); }));
  }

  // Returns true if the Tts utterance queue of TtsController running in Ash is
  // empty.
  bool IsUtteranceQueueEmpty() const {
    base::test::TestFuture<int32_t> future;
    chromeos::LacrosService::Get()
        ->GetRemote<crosapi::mojom::TestController>()
        ->GetTtsUtteranceQueueSize(future.GetCallback());
    return future.Take() == 0;
  }

  bool FoundVoiceInMojoVoices(
      const std::string& voice_name,
      const std::vector<crosapi::mojom::TtsVoicePtr>& mojo_voices) {
    return base::Contains(mojo_voices, voice_name,
                          &crosapi::mojom::TtsVoice::voice_name);
  }

  void WaitUntilTtsEventReceivedByUtteranceEventDelegateInAsh() {
    ASSERT_TRUE(
        base::test::RunUntil([&] { return tts_event_notified_in_ash_; }));
  }

  void NotifyTtsEventReceivedInAsh(content::TtsEventType tts_event) {
    tts_event_notified_in_ash_ = true;
    tts_event_received_ = tts_event;
  }

  bool TtsEventNotifiedInAsh() { return tts_event_notified_in_ash_; }
  bool TtsEventReceivedEq(content::TtsEventType expected_tts_event) {
    return tts_event_received_ == expected_tts_event;
  }

  // Used to verify that the TtsEvent is received by the Ash utterance's
  // UtteranceEventDelegate in Ash.
  class EventDelegate : public crosapi::mojom::TtsUtteranceClient {
   public:
    explicit EventDelegate(extensions::LacrosTtsApiTest* owner)
        : owner_(owner) {}
    EventDelegate(const EventDelegate&) = delete;
    EventDelegate& operator=(const EventDelegate&) = delete;
    ~EventDelegate() override = default;

    // crosapi::mojom::TtsUtteranceClient:
    void OnTtsEvent(crosapi::mojom::TtsEventType mojo_tts_event,
                    uint32_t char_index,
                    uint32_t char_length,
                    const std::string& error_message) override {
      content::TtsEventType tts_event =
          tts_crosapi_util::FromMojo(mojo_tts_event);
      owner_->NotifyTtsEventReceivedInAsh(tts_event);
    }

    mojo::PendingRemote<crosapi::mojom::TtsUtteranceClient>
    BindTtsUtteranceClient() {
      return receiver_.BindNewPipeAndPassRemoteWithVersion();
    }

   private:
    raw_ptr<extensions::LacrosTtsApiTest> owner_;
    mojo::Receiver<crosapi::mojom::TtsUtteranceClient> receiver_{this};
  };

 private:
  bool voices_changed_ = false;
  bool tts_event_notified_in_ash_ = false;
  content::TtsEventType tts_event_received_;
};

//
// TTS Engine tests.
//
IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest, LoadAndUnloadLacrosTtsEngine) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Before tts engine extension is loaded, verify the internal states are
  // clean.
  EXPECT_FALSE(VoicesChangedNotified());
  EXPECT_FALSE(HasVoiceWithName("Alice"));
  EXPECT_FALSE(HasVoiceWithName("Pat"));
  EXPECT_FALSE(HasVoiceWithName("Cat"));

  // Load tts engine extension and register the tts engine events.
  content::TtsController::GetInstance()->AddVoicesChangedDelegate(this);
  ASSERT_TRUE(RunExtensionTest("tts_engine/lacros_register_engine", {},
                               {.ignore_manifest_warnings = true}))
      << message_;

  // Wait until Lacros gets the voices registered by the tts engine extension.
  WaitUntilVoicesLoaded("Alice");

  // Verify TtsController notifies VoicesChangedDelegate for the voices change.
  EXPECT_TRUE(VoicesChangedNotified());

  // Verify all the voices from tts engine extension are returned by
  // TtsController::GetVoices().
  std::vector<content::VoiceData> voices;
  content::TtsController::GetInstance()->GetVoices(profile(), GURL(), &voices);
  EXPECT_TRUE(HasVoiceWithName("Alice"));
  EXPECT_TRUE(HasVoiceWithName("Pat"));
  EXPECT_TRUE(HasVoiceWithName("Cat"));

  ResetVoicesChanged();

  // Uninstall tts engine extension.
  extensions::TestExtensionRegistryObserver observer(
      extensions::ExtensionRegistry::Get(profile()),
      last_loaded_extension_id());
  UninstallExtension(last_loaded_extension_id());
  observer.WaitForExtensionUninstalled();

  WaitUntilVoicesUnloaded("Alice");

  // Verify TtsController notifies VoicesChangedDelegate for the voices
  // change.
  EXPECT_TRUE(VoicesChangedNotified());

  // Verify the voices from the tts engine extensions are unloaded in Lacros
  // TtsController.
  EXPECT_FALSE(HasVoiceWithName("Alice"));
  EXPECT_FALSE(HasVoiceWithName("Pat"));
  EXPECT_FALSE(HasVoiceWithName("Cat"));
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest,
                       SpeakLacrosUtteranceWithLacrosTtsEngine) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Load tts engine extension, register the tts engine events and call
  // tts.speak from the testing extension.
  ASSERT_TRUE(
      RunExtensionTest("tts_engine/lacros_tts_support/"
                       "tts_speak_lacros_utterance_with_lacros_engine",
                       {}, {.ignore_manifest_warnings = true}))
      << message_;

  // Verify the utterance issued from the testing extension is properly
  // finished and the utterance queue is empty in Ash's TtsController.
  ASSERT_TRUE(IsUtteranceQueueEmpty());
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest,
                       SpeakAshUtteranceWithLacrosSpeechEngine) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  EXPECT_FALSE(VoicesChangedNotified());
  EXPECT_FALSE(HasVoiceWithName("Alice"));
  EXPECT_FALSE(HasVoiceWithName("Alex"));
  EXPECT_FALSE(HasVoiceWithName("Amanda"));

  ResetVoicesChanged();
  content::TtsController::GetInstance()->AddVoicesChangedDelegate(this);

  // Load speech engine extension in Lacros.
  ASSERT_TRUE(
      RunExtensionTest("tts_engine/lacros_tts_support/"
                       "register_lacros_tts_engine",
                       {}, {.ignore_manifest_warnings = true}))
      << message_;

  WaitUntilVoicesLoaded("Alice");

  EXPECT_TRUE(VoicesChangedNotified());

  // Verify all the voices from tts engine extension are laded in Lacros.
  std::vector<content::VoiceData> voices;
  content::TtsController::GetInstance()->GetVoices(profile(), GURL(), &voices);
  EXPECT_TRUE(HasVoiceWithName("Alice"));
  EXPECT_TRUE(HasVoiceWithName("Alex"));
  EXPECT_TRUE(HasVoiceWithName("Amanda"));
  // Verify a random dummy voice is not loaded.
  EXPECT_FALSE(HasVoiceWithName("Tommy"));

  // Verify the same voices are also loaded in Ash.
  std::vector<crosapi::mojom::TtsVoicePtr> mojo_voices;
  ASSERT_TRUE(base::test::RunUntil([&] {
    base::test::TestFuture<std::vector<crosapi::mojom::TtsVoicePtr>>
        mojo_voices_future;
    chromeos::LacrosService::Get()
        ->GetRemote<crosapi::mojom::TestController>()
        ->GetTtsVoices(mojo_voices_future.GetCallback());
    mojo_voices = mojo_voices_future.Take();
    return !mojo_voices.empty();
  }));

  EXPECT_TRUE(FoundVoiceInMojoVoices("Alice", mojo_voices));
  EXPECT_TRUE(FoundVoiceInMojoVoices("Alex", mojo_voices));
  EXPECT_TRUE(FoundVoiceInMojoVoices("Amanda", mojo_voices));
  // Verify a random dummy voice is not loaded.
  EXPECT_FALSE(FoundVoiceInMojoVoices("Tommy", mojo_voices));

  std::unique_ptr<content::TtsUtterance> ash_utterance =
      content::TtsUtterance::Create();
  ash_utterance->SetText("Hello from Ash");
  // Use a voice provided by Lacros speech engine to speak the ash utterance.
  ash_utterance->SetVoiceName("Alice");
  crosapi::mojom::TtsUtterancePtr mojo_utterance =
      tts_crosapi_util::ToMojo(ash_utterance.get());
  // Note: mojo_utterance requires a value for browser_context_id, but it will
  // not used in Ash for the testing case.
  mojo_utterance->browser_context_id = base::UnguessableToken::Create();
  auto pending_client = std::make_unique<EventDelegate>(this);
  chromeos::LacrosService::Get()
      ->GetRemote<crosapi::mojom::TestController>()
      ->TtsSpeak(std::move(mojo_utterance),
                 pending_client->BindTtsUtteranceClient());

  // Verify that the Tts event has been received by the UtteranceEventDelegate
  // in Ash.
  WaitUntilTtsEventReceivedByUtteranceEventDelegateInAsh();
  EXPECT_TRUE(TtsEventNotifiedInAsh());
  EXPECT_TRUE(TtsEventReceivedEq(content::TTS_EVENT_END));

  // Verify the utterance issued from the testing extension is properly
  // finished and the utterance queue is empty in Ash's TtsController.
  ASSERT_TRUE(IsUtteranceQueueEmpty());
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest,
                       StopLacrosUtteranceWithLacrosTtsEngine) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Load tts engine extension, register the tts engine events and
  // call tts.speak and tts.stop from the testing extension.
  ASSERT_TRUE(
      RunExtensionTest("tts_engine/lacros_tts_support/"
                       "tts_stop_lacros_engine",
                       {}, {.ignore_manifest_warnings = true}))
      << message_;

  // Verify the utterance issued from the testing extension is properly
  // finished and the utterance queue is empty in Ash's TtsController.
  ASSERT_TRUE(IsUtteranceQueueEmpty());
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest, PauseBeforeSpeakWithLacrosTtsEngine) {
  if (chromeos::LacrosService::Get()
          ->GetInterfaceVersion<crosapi::mojom::Tts>() <
      static_cast<int>(crosapi::mojom::Tts::kPauseMinVersion)) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Load Lacros tts engine extension, register the tts engine events, and
  // call tts.pause, then tts.speak, tts.resume from the testing extension.
  ASSERT_TRUE(
      RunExtensionTest("tts_engine/lacros_tts_support/"
                       "tts_pause_before_speak_lacros_engine",
                       {}, {.ignore_manifest_warnings = true}))
      << message_;
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest, PauseDuringSpeakWithLacrosTtsEngine) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Load Lacros tts engine extension, register the tts engine events, and
  // call tts.speak, then tts.pause, tts.resume from the testing extension.
  ASSERT_TRUE(
      RunExtensionTest("tts_engine/lacros_tts_support/"
                       "tts_pause_during_speak_lacros_engine",
                       {}, {.ignore_manifest_warnings = true}))
      << message_;
}

IN_PROC_BROWSER_TEST_F(LacrosTtsApiTest, IsSpeaking) {
  if (!DoesAshSupportLacrosTtsFeatureFlagForTest()) {
    GTEST_SKIP() << "Unsupported ash version.";
  }

  // Load Lacros tts engine extension, register the tts engine events, and
  // call tts.isSpeaking before/during/after tts.speak.
  ASSERT_TRUE(RunExtensionTest("tts/is_speaking/", {},
                               {.ignore_manifest_warnings = true}))
      << message_;
}

}  // namespace extensions