blob: 0369e7f049a1334909aaea9331febc19eac471ad [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include <memory>
#include <vector>
#include "ash/accessibility/accessibility_focus_ring_controller_impl.h"
#include "ash/accessibility/accessibility_focus_ring_layer.h"
#include "ash/public/cpp/ash_features.h"
#include "ash/public/cpp/ash_view_ids.h"
#include "ash/public/cpp/system_tray_test_api.h"
#include "ash/root_window_controller.h"
#include "ash/shell.h"
#include "ash/system/status_area_widget.h"
#include "ash/system/unified/unified_system_tray.h"
#include "base/bind.h"
#include "base/command_line.h"
#include "base/memory/weak_ptr.h"
#include "build/branding_buildflags.h"
#include "chrome/browser/chromeos/accessibility/accessibility_manager.h"
#include "chrome/browser/chromeos/accessibility/speech_monitor.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/common/extensions/extension_constants.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "content/public/browser/notification_details.h"
#include "content/public/browser/notification_service.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "extensions/browser/extension_host.h"
#include "extensions/browser/notification_types.h"
#include "extensions/browser/process_manager.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "ui/accessibility/accessibility_features.h"
#include "ui/accessibility/accessibility_switches.h"
#include "ui/events/test/event_generator.h"
#include "url/url_constants.h"
namespace chromeos {
class SelectToSpeakTest : public InProcessBrowserTest {
public:
void OnFocusRingChanged() {
if (loop_runner_) {
loop_runner_->Quit();
}
}
void SetSelectToSpeakState() {
if (tray_loop_runner_) {
tray_loop_runner_->Quit();
}
}
protected:
SelectToSpeakTest() {}
~SelectToSpeakTest() override {}
void SetUpOnMainThread() override {
ASSERT_FALSE(AccessibilityManager::Get()->IsSelectToSpeakEnabled());
tray_test_api_ = ash::SystemTrayTestApi::Create();
content::WindowedNotificationObserver extension_load_waiter(
extensions::NOTIFICATION_EXTENSION_HOST_DID_STOP_FIRST_LOAD,
content::NotificationService::AllSources());
AccessibilityManager::Get()->SetSelectToSpeakEnabled(true);
extension_load_waiter.Wait();
aura::Window* root_window = ash::Shell::Get()->GetPrimaryRootWindow();
generator_.reset(new ui::test::EventGenerator(root_window));
ui_test_utils::NavigateToURL(browser(), GURL(url::kAboutBlankURL));
}
test::SpeechMonitor sm_;
std::unique_ptr<ui::test::EventGenerator> generator_;
std::unique_ptr<ash::SystemTrayTestApi> tray_test_api_;
gfx::Rect GetWebContentsBounds() const {
// TODO(katie): Find a way to get the exact bounds programmatically.
gfx::Rect bounds = browser()->window()->GetBounds();
bounds.Inset(8, 8, 75, 8);
return bounds;
}
void ActivateSelectToSpeakInWindowBounds(std::string url) {
ui_test_utils::NavigateToURL(browser(), GURL(url));
gfx::Rect bounds = GetWebContentsBounds();
// Hold down Search and drag over the web contents to select everything.
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->MoveMouseTo(bounds.x(), bounds.y());
generator_->PressLeftButton();
generator_->MoveMouseTo(bounds.x() + bounds.width(),
bounds.y() + bounds.height());
generator_->ReleaseLeftButton();
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
}
void PrepareToWaitForSelectToSpeakStatusChanged() {
tray_loop_runner_ = new content::MessageLoopRunner();
}
// Blocks until the select-to-speak tray status is changed.
void WaitForSelectToSpeakStatusChanged() {
tray_loop_runner_->Run();
tray_loop_runner_ = nullptr;
}
void TapSelectToSpeakTray() {
PrepareToWaitForSelectToSpeakStatusChanged();
tray_test_api_->TapSelectToSpeakTray();
WaitForSelectToSpeakStatusChanged();
}
void PrepareToWaitForFocusRingChanged() {
loop_runner_ = new content::MessageLoopRunner();
}
// Blocks until the focus ring is changed.
void WaitForFocusRingChanged() {
loop_runner_->Run();
loop_runner_ = nullptr;
}
base::WeakPtr<SelectToSpeakTest> GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
void RunJavaScriptInSelectToSpeakBackgroundPage(const std::string& script) {
extensions::ExtensionHost* host =
extensions::ProcessManager::Get(browser()->profile())
->GetBackgroundHostForExtension(
extension_misc::kSelectToSpeakExtensionId);
CHECK(content::ExecuteScript(host->host_contents(), script));
}
content::WebContents* GetWebContents() {
return browser()->tab_strip_model()->GetActiveWebContents();
}
void ExecuteJavaScriptAsync(const std::string& script) {
content::ExecuteScriptAsync(GetWebContents(), script);
}
private:
scoped_refptr<content::MessageLoopRunner> loop_runner_;
scoped_refptr<content::MessageLoopRunner> tray_loop_runner_;
base::WeakPtrFactory<SelectToSpeakTest> weak_ptr_factory_{this};
DISALLOW_COPY_AND_ASSIGN(SelectToSpeakTest);
};
/* Test fixture enabling experimental accessibility language detection switch */
class SelectToSpeakTestWithLanguageDetection : public SelectToSpeakTest {
protected:
void SetUpCommandLine(base::CommandLine* command_line) override {
SelectToSpeakTest::SetUpCommandLine(command_line);
command_line->AppendSwitch(
::switches::kEnableExperimentalAccessibilityLanguageDetection);
}
};
// The status tray is not active on official builds.
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
#define MAYBE_SpeakStatusTray DISABLED_SpeakStatusTray
#else
#define MAYBE_SpeakStatusTray SpeakStatusTray
#endif
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, MAYBE_SpeakStatusTray) {
gfx::Rect tray_bounds = ash::Shell::Get()
->GetPrimaryRootWindowController()
->GetStatusAreaWidget()
->unified_system_tray()
->GetBoundsInScreen();
// Hold down Search and click a few pixels into the status tray bounds.
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->MoveMouseTo(tray_bounds.x() + 8, tray_bounds.y() + 8);
generator_->PressLeftButton();
generator_->ReleaseLeftButton();
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
sm_.ExpectSpeechPattern("Status tray*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, ActivatesWithTapOnSelectToSpeakTray) {
base::RepeatingCallback<void()> callback = base::BindRepeating(
&SelectToSpeakTest::SetSelectToSpeakState, GetWeakPtr());
chromeos::AccessibilityManager::Get()->SetSelectToSpeakStateObserverForTest(
callback);
// Click in the tray bounds to start 'selection' mode.
TapSelectToSpeakTray();
// We should be in "selection" mode, so clicking with the mouse should
// start speech.
ui_test_utils::NavigateToURL(
browser(), GURL("data:text/html;charset=utf-8,<p>This is some text</p>"));
gfx::Rect bounds = GetWebContentsBounds();
generator_->MoveMouseTo(bounds.x(), bounds.y());
generator_->PressLeftButton();
generator_->MoveMouseTo(bounds.x() + bounds.width(),
bounds.y() + bounds.height());
generator_->ReleaseLeftButton();
sm_.ExpectSpeechPattern("This is some text*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, SelectToSpeakTrayNotSpoken) {
base::RepeatingCallback<void()> callback = base::BindRepeating(
&SelectToSpeakTest::SetSelectToSpeakState, GetWeakPtr());
chromeos::AccessibilityManager::Get()->SetSelectToSpeakStateObserverForTest(
callback);
// Tap it once to enter selection mode.
TapSelectToSpeakTray();
// Tap again to turn off selection mode.
TapSelectToSpeakTray();
// The next should be the first thing spoken -- the tray was not spoken.
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<p>This is some text</p>");
sm_.ExpectSpeechPattern("This is some text*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, SmoothlyReadsAcrossInlineUrl) {
// Make sure an inline URL is read smoothly.
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<p>This is some text <a href=\"\">with a"
" node</a> in the middle");
// Should combine nodes in a paragraph into one utterance.
// Includes some wildcards between words because there may be extra
// spaces. Spaces are not pronounced, so extra spaces do not impact output.
sm_.ExpectSpeechPattern("This is some text*with a node*in the middle*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, SmoothlyReadsAcrossMultipleLines) {
// Sentences spanning multiple lines.
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<div style=\"width:100px\">This"
" is some text with a node in the middle");
// Should combine nodes in a paragraph into one utterance.
// Includes some wildcards between words because there may be extra
// spaces, for example at line wraps. Extra wildcards included to
// reduce flakyness in case wrapping is not consistent.
// Spaces are not pronounced, so extra spaces do not impact output.
sm_.ExpectSpeechPattern("This is some*text*with*a*node*in*the*middle*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, SmoothlyReadsAcrossFormattedText) {
// Bold or formatted text
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<p>This is some text <b>with a node"
"</b> in the middle");
// Should combine nodes in a paragraph into one utterance.
// Includes some wildcards between words because there may be extra
// spaces. Spaces are not pronounced, so extra spaces do not impact output.
sm_.ExpectSpeechPattern("This is some text*with a node*in the middle*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest,
ReadsStaticTextWithoutInlineTextChildren) {
// Bold or formatted text
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<canvas>This is some text</canvas>");
sm_.ExpectSpeechPattern("This is some text*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, BreaksAtParagraphBounds) {
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<div><p>First paragraph</p>"
"<p>Second paragraph</p></div>");
// Should keep each paragraph as its own utterance.
sm_.ExpectSpeechPattern("First paragraph*");
sm_.ExpectSpeechPattern("Second paragraph*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, LanguageBoundsIgnoredByDefault) {
// Splitting at language bounds is behind a feature flag, test the default
// behaviour doesn't introduce a regression.
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<div>"
"<span lang='en-US'>The first paragraph</span>"
"<span lang='fr-FR'>la deuxième paragraphe</span></div>");
sm_.ExpectSpeechPattern("The first paragraph* la deuxième paragraphe*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTestWithLanguageDetection,
BreaksAtLanguageBounds) {
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<div>"
"<span lang='en-US'>The first paragraph</span>"
"<span lang='fr-FR'>la deuxième paragraphe</span></div>");
sm_.ExpectSpeechPatternWithLocale("The first paragraph*", "en-US");
sm_.ExpectSpeechPatternWithLocale("la deuxième paragraphe*", "fr-FR");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, DoesNotCrashWithMousewheelEvent) {
ui_test_utils::NavigateToURL(
browser(), GURL("data:text/html;charset=utf-8,<p>This is some text</p>"));
gfx::Rect bounds = GetWebContentsBounds();
// Hold down Search and drag over the web contents to select everything.
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->MoveMouseTo(bounds.x(), bounds.y());
generator_->PressLeftButton();
// Ensure this does not crash. It should have no effect.
generator_->MoveMouseWheel(10, 10);
generator_->MoveMouseTo(bounds.x() + bounds.width(),
bounds.y() + bounds.height());
generator_->MoveMouseWheel(100, 5);
generator_->ReleaseLeftButton();
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
sm_.ExpectSpeechPattern("This is some text*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, FocusRingMovesWithMouse) {
// Create a callback for the focus ring observer.
base::RepeatingCallback<void()> callback =
base::BindRepeating(&SelectToSpeakTest::OnFocusRingChanged, GetWeakPtr());
chromeos::AccessibilityManager::Get()->SetFocusRingObserverForTest(callback);
std::string focus_ring_id =
chromeos::AccessibilityManager::Get()->GetFocusRingId(
extension_misc::kSelectToSpeakExtensionId, "");
ash::AccessibilityFocusRingControllerImpl* controller =
ash::Shell::Get()->accessibility_focus_ring_controller();
controller->SetNoFadeForTesting();
const ash::AccessibilityFocusRingGroup* focus_ring_group =
controller->GetFocusRingGroupForTesting(focus_ring_id);
// No focus rings to start.
EXPECT_EQ(nullptr, focus_ring_group);
ui_test_utils::NavigateToURL(browser(), GURL("data:text/html;charset=utf-8,"
"<p>This is some text</p>"));
gfx::Rect bounds = GetWebContentsBounds();
PrepareToWaitForFocusRingChanged();
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->MoveMouseTo(bounds.x(), bounds.y());
generator_->PressLeftButton();
// Expect a focus ring to have been drawn.
WaitForFocusRingChanged();
focus_ring_group = controller->GetFocusRingGroupForTesting(focus_ring_id);
ASSERT_NE(nullptr, focus_ring_group);
std::vector<std::unique_ptr<ash::AccessibilityFocusRingLayer>> const&
focus_rings = focus_ring_group->focus_layers_for_testing();
EXPECT_EQ(focus_rings.size(), 1u);
gfx::Rect target_bounds = focus_rings.at(0)->layer()->GetTargetBounds();
// Make sure it's in a reasonable position.
EXPECT_LT(abs(target_bounds.x() - bounds.x()), 50);
EXPECT_LT(abs(target_bounds.y() - bounds.y()), 50);
EXPECT_LT(target_bounds.width(), 50);
EXPECT_LT(target_bounds.height(), 50);
// Move the mouse.
PrepareToWaitForFocusRingChanged();
generator_->MoveMouseTo(bounds.x() + 100, bounds.y() + 100);
// Expect focus ring to have moved with the mouse.
// The size should have grown to be over 100 (the rect is now size 100,
// and the focus ring has some buffer). Position should be unchanged.
WaitForFocusRingChanged();
target_bounds = focus_rings.at(0)->layer()->GetTargetBounds();
EXPECT_LT(abs(target_bounds.x() - bounds.x()), 50);
EXPECT_LT(abs(target_bounds.y() - bounds.y()), 50);
EXPECT_GT(target_bounds.width(), 100);
EXPECT_GT(target_bounds.height(), 100);
// Move the mouse smaller again, it should shrink.
PrepareToWaitForFocusRingChanged();
generator_->MoveMouseTo(bounds.x() + 10, bounds.y() + 18);
WaitForFocusRingChanged();
target_bounds = focus_rings.at(0)->layer()->GetTargetBounds();
EXPECT_LT(target_bounds.width(), 50);
EXPECT_LT(target_bounds.height(), 50);
// Cancel this by releasing the key before the mouse.
PrepareToWaitForFocusRingChanged();
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->ReleaseLeftButton();
// Expect focus ring to have been cleared, this was canceled in STS
// by releasing the key before the button.
WaitForFocusRingChanged();
EXPECT_EQ(focus_rings.size(), 0u);
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, ContinuesReadingDuringResize) {
ActivateSelectToSpeakInWindowBounds(
"data:text/html;charset=utf-8,<p>First paragraph</p>"
"<div id='resize' style='width:300px; font-size: 1em'>"
"<p>Second paragraph is longer than 300 pixels and will wrap when "
"resized</p></div>");
sm_.ExpectSpeechPattern("First paragraph*");
// Resize before second is spoken. If resizing caused errors finding the
// inlineTextBoxes in the node, speech would be stopped early.
sm_.Call([this]() {
ExecuteJavaScriptAsync(
"document.getElementById('resize').style.width='100px'");
});
sm_.ExpectSpeechPattern("*when*resized*");
sm_.Replay();
}
IN_PROC_BROWSER_TEST_F(SelectToSpeakTest, WorksWithStickyKeys) {
AccessibilityManager::Get()->EnableStickyKeys(true);
ui_test_utils::NavigateToURL(
browser(), GURL("data:text/html;charset=utf-8,<p>This is some text</p>"));
// Tap Search and click a few pixels into the window bounds.
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
// Sticky keys should remember the 'search' key was clicked, so STS is
// actually in a capturing mode now.
gfx::Rect bounds = GetWebContentsBounds();
generator_->MoveMouseTo(bounds.x(), bounds.y());
generator_->PressLeftButton();
generator_->MoveMouseTo(bounds.x() + bounds.width(),
bounds.y() + bounds.height());
generator_->ReleaseLeftButton();
sm_.ExpectSpeechPattern("This is some text*");
// Reset state.
sm_.Call([]() { AccessibilityManager::Get()->EnableStickyKeys(false); });
sm_.Replay();
}
/* Test fixture enabling navigation control */
class SelectToSpeakTestWithNavigationControl : public SelectToSpeakTest {
public:
void SetUpInProcessBrowserTestFixture() override {
scoped_feature_list_.InitAndEnableFeature(
::features::kSelectToSpeakNavigationControl);
}
private:
base::test::ScopedFeatureList scoped_feature_list_;
};
IN_PROC_BROWSER_TEST_F(SelectToSpeakTestWithNavigationControl,
SelectToSpeakDoesNotDismissTrayBubble) {
// Open tray bubble menu.
tray_test_api_->ShowBubble();
// Search key + click the avatar button.
generator_->PressKey(ui::VKEY_LWIN, 0 /* flags */);
tray_test_api_->ClickBubbleView(ash::VIEW_ID_USER_AVATAR_BUTTON);
generator_->ReleaseKey(ui::VKEY_LWIN, 0 /* flags */);
// Should read out text.
sm_.ExpectSpeechPattern("*stub-user@example.com*");
sm_.Replay();
// Tray bubble menu should remain open.
ASSERT_TRUE(tray_test_api_->IsTrayBubbleOpen());
}
} // namespace chromeos