// 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/accessibility/live_caption/live_caption_surface.h"
#include <optional>
#include "base/path_service.h"
#include "base/test/bind.h"
#include "base/unguessable_token.h"
#include "chrome/app/chrome_command_ids.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "content/public/browser/web_contents_delegate.h"
#include "content/public/browser/web_contents_observer.h"
#include "content/public/common/content_paths.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
#include "ui/base/page_transition_types.h"
#include "ui/gfx/geometry/rect.h"
#include "url/gurl.h"
#include "url/url_constants.h"
namespace captions {
namespace {
// A WebContentsObserver that runs tasks until some media starts / stops playing
// fullscreen.
class ScopedFullscreenRunner : public content::WebContentsObserver {
public:
explicit ScopedFullscreenRunner(content::WebContents* web_contents)
: WebContentsObserver(web_contents) {}
ScopedFullscreenRunner(const ScopedFullscreenRunner&) = delete;
ScopedFullscreenRunner& operator=(const ScopedFullscreenRunner&) = delete;
~ScopedFullscreenRunner() override {
// On destruction, wait for any fullscreen hooks to have executed. Then,
// allow any tasks scheduled by the hooks to complete too.
run_loop_.Run();
base::RunLoop().RunUntilIdle();
}
void MediaEffectivelyFullscreenChanged(bool /*is_fullscreen*/) override {
run_loop_.Quit();
}
private:
base::RunLoop run_loop_;
};
// A surface client whose methods can have expectations placed on them.
class MockSurfaceClient : public media::mojom::SpeechRecognitionSurfaceClient {
public:
MockSurfaceClient() = default;
~MockSurfaceClient() override = default;
MockSurfaceClient(const MockSurfaceClient&) = delete;
MockSurfaceClient& operator=(const MockSurfaceClient&) = delete;
// media::mojom::SpeechRecognitionSurfaceClient:
MOCK_METHOD(void, OnSessionEnded, (), (override));
MOCK_METHOD(void, OnFullscreenToggled, (), (override));
void BindToSurface(
mojo::PendingReceiver<media::mojom::SpeechRecognitionSurfaceClient>
receiver,
mojo::PendingRemote<media::mojom::SpeechRecognitionSurface> surface) {
receiver_.Bind(std::move(receiver));
surface_.Bind(std::move(surface));
}
private:
// The remote must be held somewhere even though we don't call methods on it.
mojo::Remote<media::mojom::SpeechRecognitionSurface> surface_;
mojo::Receiver<media::mojom::SpeechRecognitionSurfaceClient> receiver_{this};
};
class LiveCaptionSurfaceTest : public InProcessBrowserTest {
public:
LiveCaptionSurfaceTest() = default;
~LiveCaptionSurfaceTest() override = default;
LiveCaptionSurfaceTest(const LiveCaptionSurfaceTest&) = delete;
LiveCaptionSurfaceTest& operator=(const LiveCaptionSurfaceTest&) = delete;
void SetUpOnMainThread() override {
InProcessBrowserTest::SetUpOnMainThread();
// Load premade test pages.
base::FilePath test_data_dir;
CHECK(base::PathService::Get(content::DIR_TEST_DATA, &test_data_dir));
embedded_test_server()->ServeFilesFromDirectory(test_data_dir);
CHECK(embedded_test_server()->Start());
}
// Opens a new tab and waits for it to load the given URL.
content::WebContents* LoadNewTab(GURL url) {
content::RenderFrameHost* rfh = ui_test_utils::NavigateToURLWithDisposition(
browser(), url, WindowOpenDisposition::NEW_FOREGROUND_TAB,
ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
return content::WebContents::FromRenderFrameHost(rfh);
}
// Attaches a new surface to the given web contents and binds it to the given
// client.
LiveCaptionSurface* NewSurfaceForWebContents(
content::WebContents* web_contents,
MockSurfaceClient* mock_client) {
mojo::PendingReceiver<media::mojom::SpeechRecognitionSurface>
surface_receiver;
mojo::PendingRemote<media::mojom::SpeechRecognitionSurfaceClient>
client_remote;
mock_client->BindToSurface(client_remote.InitWithNewPipeAndPassReceiver(),
surface_receiver.InitWithNewPipeAndPassRemote());
auto* surface = LiveCaptionSurface::GetOrCreateForWebContents(web_contents);
surface->BindToSurfaceClient(std::move(surface_receiver),
std::move(client_remote));
return surface;
}
const GURL kAboutBlankUrl = GURL(url::kAboutBlankURL);
};
// Test that the surface can be used to focus its tab.
IN_PROC_BROWSER_TEST_F(LiveCaptionSurfaceTest, Activate) {
// Create an initial tab with a surface attached.
MockSurfaceClient client;
content::WebContents* wc_1 = LoadNewTab(kAboutBlankUrl);
LiveCaptionSurface* surface = NewSurfaceForWebContents(wc_1, &client);
// The initial tab should be focused.
EXPECT_EQ(browser()->tab_strip_model()->GetActiveWebContents(), wc_1);
// Create a new tab, which should start focused.
content::WebContents* wc_2 = LoadNewTab(kAboutBlankUrl);
EXPECT_EQ(browser()->tab_strip_model()->GetActiveWebContents(), wc_2);
// Activating the first tab via its surface should bring it into focus.
surface->Activate();
EXPECT_EQ(browser()->tab_strip_model()->GetActiveWebContents(), wc_1);
}
// Test that the surface correctly reports tab bounds on screen.
IN_PROC_BROWSER_TEST_F(LiveCaptionSurfaceTest, Bounds) {
// Create a tab with a surface attached.
MockSurfaceClient client;
LiveCaptionSurface* surface =
NewSurfaceForWebContents(LoadNewTab(kAboutBlankUrl), &client);
// Callback to assign bounds to local variables.
const auto assign_bounds = [](gfx::Rect* d,
const std::optional<gfx::Rect>& b) {
ASSERT_TRUE(b.has_value());
*d = *b;
};
// Set known window bounds.
const gfx::Rect window_bounds_1 = gfx::Rect(10, 10, 800, 600);
browser()->window()->SetBounds(window_bounds_1);
// Fetch bounds using the surface.
gfx::Rect bounds_1;
surface->GetBounds(base::BindOnce(assign_bounds, &bounds_1));
base::RunLoop().RunUntilIdle();
// The surface should have correctly reported the window bounds.
EXPECT_EQ(window_bounds_1, bounds_1);
// Set new window bounds.
const gfx::Rect window_bounds_2 = gfx::Rect(50, 50, 800, 600);
browser()->window()->SetBounds(window_bounds_2);
// Fetch bounds using the surface.
gfx::Rect bounds_2;
surface->GetBounds(base::BindOnce(assign_bounds, &bounds_2));
base::RunLoop().RunUntilIdle();
// The surface should have correctly reported the window bounds.
EXPECT_EQ(window_bounds_2, bounds_2);
}
// Test that the client is informed of fullscreen changes.
IN_PROC_BROWSER_TEST_F(LiveCaptionSurfaceTest, Fullscreen) {
// Load a pre-prepared page that defines JS to (un/)fullscreen the tab.
MockSurfaceClient client;
content::WebContents* web_contents =
LoadNewTab(embedded_test_server()->GetURL("/media/fullscreen.html"));
NewSurfaceForWebContents(web_contents, &client);
// We use a mock function to set up "checkpoints" by which each side effect
// should have occurred.
testing::MockFunction<void(int)> checkpointer;
{
testing::InSequence seq;
// Fullscreen media on the page.
EXPECT_CALL(client, OnFullscreenToggled());
EXPECT_CALL(checkpointer, Call(1));
{
ScopedFullscreenRunner runner(web_contents);
EXPECT_TRUE(
content::ExecJs(web_contents, "makeFullscreen('small_video')"));
}
checkpointer.Call(1);
// Un-fullscreen the media.
EXPECT_CALL(client, OnFullscreenToggled());
EXPECT_CALL(checkpointer, Call(2));
{
ScopedFullscreenRunner runner(web_contents);
EXPECT_TRUE(content::ExecJs(web_contents, "exitFullscreen()"));
}
checkpointer.Call(2);
}
}
// Test that the surface correctly reports session IDs.
IN_PROC_BROWSER_TEST_F(LiveCaptionSurfaceTest, SessionIds) {
// Create two tabs with two surfaces.
MockSurfaceClient client_1, client_2;
LiveCaptionSurface* surface_1 =
NewSurfaceForWebContents(LoadNewTab(kAboutBlankUrl), &client_1);
LiveCaptionSurface* surface_2 =
NewSurfaceForWebContents(LoadNewTab(kAboutBlankUrl), &client_2);
// The session IDs of the two surfaces should be different because they
// represent different web contents.
const base::UnguessableToken init_id_1 = surface_1->session_id();
EXPECT_NE(init_id_1, surface_2->session_id());
// Navigating a tab shouldn't change its session ID.
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), kAboutBlankUrl));
EXPECT_EQ(init_id_1, surface_1->session_id());
}
// Test that a surface reports the end of live caption sessions.
IN_PROC_BROWSER_TEST_F(LiveCaptionSurfaceTest, Sessions) {
// Create two tabs with surfaces attached.
MockSurfaceClient client_1, client_2;
content::WebContents* wc_1 = LoadNewTab(kAboutBlankUrl);
content::WebContents* wc_2 = LoadNewTab(kAboutBlankUrl);
LiveCaptionSurface* surface_1 = NewSurfaceForWebContents(wc_1, &client_1);
NewSurfaceForWebContents(wc_2, &client_2);
// We use a mock function to set up "checkpoints" by which each side effect
// should have occurred.
testing::MockFunction<void(int)> checkpointer;
{
testing::InSequence seq;
// We will start by making a navigation in the focused tab. We expect this
// should end its session, but not the other tab's.
EXPECT_CALL(client_1, OnSessionEnded()).Times(0);
EXPECT_CALL(client_2, OnSessionEnded());
EXPECT_CALL(checkpointer, Call(1));
ASSERT_EQ(wc_2, browser()->tab_strip_model()->GetActiveWebContents());
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), kAboutBlankUrl));
base::RunLoop().RunUntilIdle();
checkpointer.Call(1);
// Next we will refresh the first tab. We expect this should end its
// session.
EXPECT_CALL(client_1, OnSessionEnded());
EXPECT_CALL(checkpointer, Call(2));
surface_1->Activate();
ASSERT_EQ(wc_1, browser()->tab_strip_model()->GetActiveWebContents());
chrome::Reload(browser(), WindowOpenDisposition::CURRENT_TAB);
content::WaitForLoadStop(wc_1);
base::RunLoop().RunUntilIdle();
checkpointer.Call(2);
// Lastly, we will navigate from the first tab. This should end the session
// again.
EXPECT_CALL(client_1, OnSessionEnded());
EXPECT_CALL(checkpointer, Call(3));
// Navigating from the first tab should end its session again.
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), kAboutBlankUrl));
base::RunLoop().RunUntilIdle();
checkpointer.Call(3);
}
}
} // namespace
} // namespace captions