// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ash/accessibility/dictation_test_utils.h"
#include <string_view>
#include "ash/constants/ash_pref_names.h"
#include "ash/shell.h"
#include "base/base_paths.h"
#include "base/containers/fixed_flat_set.h"
#include "base/files/file_util.h"
#include "base/path_service.h"
#include "base/strings/utf_string_conversions.h"
#include "base/threading/thread_restrictions.h"
#include "chrome/browser/ash/accessibility/accessibility_manager.h"
#include "chrome/browser/ash/accessibility/accessibility_test_utils.h"
#include "chrome/browser/ash/accessibility/automation_test_utils.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/speech/speech_recognition_constants.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/common/extensions/extension_constants.h"
#include "chrome/test/base/interactive_test_utils.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/prefs/pref_service.h"
#include "content/public/test/fake_speech_recognition_manager.h"
#include "extensions/browser/browsertest_util.h"
#include "extensions/browser/extension_host_test_helper.h"
#include "media/mojo/mojom/speech_recognition_service.mojom.h"
#include "ui/base/clipboard/clipboard_monitor.h"
#include "ui/base/clipboard/clipboard_observer.h"
#include "ui/base/ime/ash/ime_bridge.h"
#include "ui/base/ime/ash/mock_ime_input_context_handler.h"
#include "ui/base/ime/input_method_base.h"
#include "ui/events/test/event_generator.h"
#include "url/gurl.h"
namespace ash {
namespace {
constexpr char kContentEditableUrl[] = R"(
data:text/html;charset=utf-8,<div id='input' class='editableForDictation'
contenteditable autofocus></div>
)";
constexpr char kFormattedContentEditableUrl[] = R"(
data:text/html;charset=utf-8,<div id='input' class='editableForDictation'
contenteditable autofocus>
<p><strong>This</strong> <b>is</b> a <em>test</em></p></div>
)";
constexpr char kInputUrl[] = R"(
data:text/html;charset=utf-8,<input id='input' class='editableForDictation'
type='text' autofocus></input>
)";
constexpr char kTextAreaUrl[] = R"(
data:text/html;charset=utf-8,<textarea id='input'
class='editableForDictation' autofocus></textarea>
)";
constexpr char kPumpkinTestFilePath[] =
"resources/chromeos/accessibility/accessibility_common/third_party/pumpkin";
constexpr char kTestSupportPath[] =
"chrome/browser/resources/chromeos/accessibility/accessibility_common/"
"dictation/dictation_test_support.js";
// Listens for changes to the clipboard. This class only allows `Wait()` to be
// called once. If you need to call `Wait()` multiple times, create multiple
// instances of this class.
class ClipboardChangedWaiter : public ui::ClipboardObserver {
public:
ClipboardChangedWaiter() {
ui::ClipboardMonitor::GetInstance()->AddObserver(this);
}
ClipboardChangedWaiter(const ClipboardChangedWaiter&) = delete;
ClipboardChangedWaiter& operator=(const ClipboardChangedWaiter&) = delete;
~ClipboardChangedWaiter() override {
ui::ClipboardMonitor::GetInstance()->RemoveObserver(this);
}
void Wait() { run_loop_.Run(); }
private:
// ui::ClipboardObserver:
void OnClipboardDataChanged() override { run_loop_.Quit(); }
base::RunLoop run_loop_;
};
// Listens to when the IME commits text. This class only allows `Wait()` to be
// called once. If you need to call `Wait()` multiple times, create multiple
// instances of this class.
class CommitTextWaiter : public MockIMEInputContextHandler::Observer {
public:
CommitTextWaiter() = default;
CommitTextWaiter(const CommitTextWaiter&) = delete;
CommitTextWaiter& operator=(const CommitTextWaiter&) = delete;
~CommitTextWaiter() override = default;
void Wait(const std::u16string& expected_commit_text) {
expected_commit_text_ = expected_commit_text;
run_loop_.Run();
}
private:
// MockIMEInputContextHandler::Observer
void OnCommitText(const std::u16string& text) override {
if (text == expected_commit_text_) {
run_loop_.Quit();
}
}
std::u16string expected_commit_text_;
base::RunLoop run_loop_;
};
} // namespace
DictationTestUtils::DictationTestUtils(
speech::SpeechRecognitionType speech_recognition_type,
EditableType editable_type)
: wait_for_accessibility_common_extension_load_(true),
speech_recognition_type_(speech_recognition_type),
editable_type_(editable_type) {
automation_test_utils_ = std::make_unique<AutomationTestUtils>(
extension_misc::kAccessibilityCommonExtensionId);
test_helper_ = std::make_unique<SpeechRecognitionTestHelper>(
speech_recognition_type, media::mojom::RecognizerClientType::kDictation);
}
DictationTestUtils::~DictationTestUtils() {
if (speech_recognition_type_ == speech::SpeechRecognitionType::kNetwork) {
content::SpeechRecognitionManager::SetManagerForTesting(nullptr);
}
}
void DictationTestUtils::EnableDictation(
Profile* profile,
base::OnceCallback<void(const GURL&)> navigate_to_url) {
profile_ = profile;
console_observer_ = std::make_unique<ExtensionConsoleErrorObserver>(
profile_, extension_misc::kAccessibilityCommonExtensionId);
generator_ = std::make_unique<ui::test::EventGenerator>(
Shell::Get()->GetPrimaryRootWindow());
// Set up the Pumpkin dir before turning on Dictation because the
// extension will immediately request a Pumpkin installation once activated.
DictationTestUtils::SetUpPumpkinDir();
test_helper_->SetUp(profile_);
ASSERT_FALSE(AccessibilityManager::Get()->IsDictationEnabled());
profile_->GetPrefs()->SetBoolean(
prefs::kDictationAcceleratorDialogHasBeenAccepted, true);
if (wait_for_accessibility_common_extension_load_) {
// Use ExtensionHostTestHelper to detect when the accessibility common
// extension loads.
extensions::ExtensionHostTestHelper host_helper(
profile_, extension_misc::kAccessibilityCommonExtensionId);
AccessibilityManager::Get()->SetDictationEnabled(true);
host_helper.WaitForHostCompletedFirstLoad();
} else {
// In some cases (e.g. DictationWithAutoclickTest) the accessibility
// common extension is already setup and loaded. For these cases, simply
// toggle Dictation.
AccessibilityManager::Get()->SetDictationEnabled(true);
}
std::string url = GetUrlForEditableType();
std::move(navigate_to_url).Run(GURL(url));
// Dictation test support references the main Dictation object, so wait for
// the main object to be created before installing test support.
WaitForDictationJSReady();
// Setup automation test support.
automation_test_utils_->SetUpTestSupport();
// Create an instance of the DictationTestSupport JS class, which can be
// used from these tests to interact with Dictation JS. For more
// information, see kTestSupportPath.
SetUpTestSupport();
// Wait for focus to propagate.
WaitForEditableFocus();
// Increase Dictation's NO_FOCUSED_IME timeout to reduce flakiness on slower
// builds.
std::string script =
"dictationTestSupport.setNoFocusedImeTimeout(1000 * 1000);";
ExecuteAccessibilityCommonScript(script);
// Dictation will request a Pumpkin install when it starts up. Wait for
// the install to succeed.
WaitForPumpkinTaggerReady();
}
void DictationTestUtils::ToggleDictationWithKeystroke() {
ASSERT_NO_FATAL_FAILURE(ASSERT_TRUE(ui_test_utils::SendKeyPressToWindowSync(
nullptr, ui::KeyboardCode::VKEY_D, false, false, false, true)));
}
void DictationTestUtils::SendFinalResultAndWaitForEditableValue(
const std::string& result,
const std::string& value) {
SendFinalResultAndWait(result);
if (speech_recognition_type_ == speech::SpeechRecognitionType::kNetwork) {
automation_test_utils_->WaitForValueChangedEvent();
}
WaitForEditableValue(value);
}
void DictationTestUtils::SendFinalResultAndWaitForSelection(
const std::string& result,
int start,
int end) {
SendFinalResultAndWait(result);
automation_test_utils_->WaitForTextSelectionChangedEvent();
WaitForSelection(start, end);
}
void DictationTestUtils::SendFinalResultAndWaitForClipboardChanged(
const std::string& result) {
ClipboardChangedWaiter waiter;
SendFinalResultAndWait(result);
waiter.Wait();
}
void DictationTestUtils::WaitForRecognitionStarted() {
test_helper_->WaitForRecognitionStarted();
// Dictation initializes FocusHandler when speech recognition starts.
// Several tests require FocusHandler logic, so wait for it to initialize
// before proceeding.
WaitForFocusHandler();
}
void DictationTestUtils::WaitForRecognitionStopped() {
test_helper_->WaitForRecognitionStopped();
}
void DictationTestUtils::SendInterimResultAndWait(
const std::string& transcript) {
test_helper_->SendInterimResultAndWait(transcript);
}
void DictationTestUtils::SendFinalResultAndWait(const std::string& transcript) {
test_helper_->SendFinalResultAndWait(transcript);
}
void DictationTestUtils::SendErrorAndWait() {
test_helper_->SendErrorAndWait();
}
std::vector<base::test::FeatureRef> DictationTestUtils::GetEnabledFeatures() {
return test_helper_->GetEnabledFeatures();
}
std::vector<base::test::FeatureRef> DictationTestUtils::GetDisabledFeatures() {
return test_helper_->GetDisabledFeatures();
}
void DictationTestUtils::ExecuteAccessibilityCommonScript(
const std::string& script) {
extensions::browsertest_util::ExecuteScriptInBackgroundPage(
/*context=*/profile_,
/*extension_id=*/extension_misc::kAccessibilityCommonExtensionId,
/*script=*/script);
}
void DictationTestUtils::DisablePumpkin() {
std::string script = "dictationTestSupport.disablePumpkin();";
ExecuteAccessibilityCommonScript(script);
}
std::string DictationTestUtils::GetUrlForEditableType() {
switch (editable_type_) {
case EditableType::kTextArea:
return kTextAreaUrl;
case EditableType::kFormattedContentEditable:
return kFormattedContentEditableUrl;
case EditableType::kInput:
return kInputUrl;
case EditableType::kContentEditable:
return kContentEditableUrl;
}
}
std::string DictationTestUtils::GetEditableValue() {
return automation_test_utils_->GetValueForNodeWithClassName(
"editableForDictation");
}
void DictationTestUtils::WaitForEditableValue(const std::string& value) {
std::string script = base::StringPrintf(
"dictationTestSupport.waitForEditableValue(`%s`);", value.c_str());
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::WaitForSelection(int start, int end) {
std::string script = base::StringPrintf(
"dictationTestSupport.waitForSelection(%d, %d);", start, end);
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::InstallMockInputContextHandler() {
input_context_handler_ = std::make_unique<MockIMEInputContextHandler>();
IMEBridge::Get()->SetInputContextHandler(input_context_handler_.get());
}
// Retrieves the number of times commit text is updated.
int DictationTestUtils::GetCommitTextCallCount() {
EXPECT_TRUE(input_context_handler_);
return input_context_handler_->commit_text_call_count();
}
void DictationTestUtils::WaitForCommitText(const std::u16string& value) {
if (value == input_context_handler_->last_commit_text()) {
return;
}
CommitTextWaiter waiter;
input_context_handler_->AddObserver(&waiter);
waiter.Wait(value);
input_context_handler_->RemoveObserver(&waiter);
}
void DictationTestUtils::SetUpPumpkinDir() {
// Set the path to the Pumpkin test files. For more details, see the
// `pumpkin_test_files` rule in the accessibility_common BUILD file.
base::ScopedAllowBlockingForTesting allow_blocking;
base::FilePath gen_root_dir;
ASSERT_TRUE(
base::PathService::Get(base::DIR_OUT_TEST_DATA_ROOT, &gen_root_dir));
base::FilePath pumpkin_test_file_path =
gen_root_dir.AppendASCII(kPumpkinTestFilePath);
ASSERT_TRUE(base::PathExists(pumpkin_test_file_path));
AccessibilityManager::Get()->SetDlcPathForTest(pumpkin_test_file_path);
}
void DictationTestUtils::SetUpTestSupport() {
base::ScopedAllowBlockingForTesting allow_blocking;
base::FilePath source_dir;
CHECK(base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &source_dir));
auto test_support_path = source_dir.AppendASCII(kTestSupportPath);
std::string script;
ASSERT_TRUE(base::ReadFileToString(test_support_path, &script))
<< test_support_path;
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::WaitForDictationJSReady() {
std::string script = base::StringPrintf(R"JS(
(async function() {
window.accessibilityCommon.setFeatureLoadCallbackForTest('dictation',
() => {
chrome.test.sendScriptResult('ready');
});
})();
)JS");
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::WaitForEditableFocus() {
std::string script = "dictationTestSupport.waitForEditableFocus();";
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::WaitForPumpkinTaggerReady() {
std::string locale =
profile_->GetPrefs()->GetString(prefs::kAccessibilityDictationLocale);
static constexpr auto kPumpkinLocales =
base::MakeFixedFlatSet<std::string_view>(
{"en-US", "fr-FR", "it-IT", "de-DE", "es-ES"});
if (!base::Contains(kPumpkinLocales, locale)) {
// If Pumpkin doesn't support the dictation locale, then it will never
// initialize.
return;
}
std::string script = "dictationTestSupport.waitForPumpkinTaggerReady();";
ExecuteAccessibilityCommonScript(script);
}
void DictationTestUtils::WaitForFocusHandler() {
std::string script = R"(
dictationTestSupport.waitForFocusHandler('editableForDictation');
)";
ExecuteAccessibilityCommonScript(script);
}
} // namespace ash