blob: edb0f46f0153c1e04edad3b515ac3556f044ca88 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef CHROME_BROWSER_GLIC_TEST_SUPPORT_GLIC_API_TEST_H_
#define CHROME_BROWSER_GLIC_TEST_SUPPORT_GLIC_API_TEST_H_
#include <type_traits>
#include "base/json/json_writer.h"
#include "base/test/run_until.h"
#include "base/test/test_timeouts.h"
#include "base/values.h"
#include "chrome/browser/glic/test_support/interactive_glic_test.h"
#include "chrome/browser/glic/test_support/non_interactive_glic_test.h"
#include "chrome/test/interaction/interactive_browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "net/dns/mock_host_resolver.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace glic {
// This file defines Glic API test fixtures NonInteractiveGlicApiTest and
// InteractiveGlicApiTest.
// These fixtures configure a Glic client that runs test code. Each .cc test
// file corresponds to a .ts file. The .cc file runs browser-side code, and the
// .ts file runs glic client code. Each gtest in the .cc file should correspond
// to a test function in the .ts file.
// Using these fixtures requires a little bit of setup. Example:
//
// class MyNewGlicTest : public NonInteractiveGlicApiTest {
// public:
// MyNewGlicTest() :
// // Make a new .ts file in chrome/test/data/webui/glic/browser_tests
// // update build rules, and point to the generated .js file here.
// NonInteractiveGlicApiTest("./my_new_glic_test_browsertest.js") {}
// };
//
// // Always include this test in one fixture of your test file. It ensures
// // the set of tests in the .ts file match the set of tests in the .cc file.
// IN_PROC_BROWSER_TEST_F(MyNewGlicTest, testAllTestsAreRegistered) {
// // Include all test fixture names here.
// AssertAllTestsRegistered({
// "MyNewGlicTest",
// });
// }
// // Add test cases...
//
// Next, here's boilerplate for the my_new_glic_test_browsertest.ts file:
//
// import {ApiTestFixtureBase, testMain} from './browser_test_base.js';
//
// class MyNewGlicTest extends ApiTestFixtureBase {
// // Normally it's useful to wait until the client is shown to start the test
// // but is not necessary.
// override async setUpTest() {
// await this.client.waitForFirstOpen();
// }
// // Add test cases...
// }
// testMain([
// // All test fixtures need to be listed here.
// MyNewGlicTest,
// ]);
struct ExecuteTestOptions {
// Test parameters passed to the JS test. See `ApiTestFixtureBase.testParams`.
base::Value params;
// Assert that the test function does not return, and instead destroys the
// test frame.
bool expect_guest_frame_destroyed = false;
// Whether to wait for the guest before starting the test.
// TODO(harringtond): This should always be true, but I'm seeing this error
// for tests where wait_for_guest needs to be overridden:
// DCHECK failed: false. Non-Profile BrowserContext passed to
// Profile::FromBrowserContext! If you have a test linked in chrome/ you
// need a chrome/ based test class such as TestingProfile in
// chrome/test/base/testing_profile.h or you need to subclass your test
// class from Profile, not from BrowserContext.
bool wait_for_guest = true;
// Expect that the JS execution should return a failure. Used for internal
// test harness testing.
bool should_fail = false;
// Only considered if `should_fail` is true. This value can be set to the
// expected string output of the JS error. If this is not set, will only check
// that the JS result is not "pass".
std::string_view should_fail_with_error;
};
// Observes the state of the WebUI hosted in the glic window.
class WebUIStateListener : public Host::Observer {
public:
explicit WebUIStateListener(Host* host);
~WebUIStateListener() override;
void WebUiStateChanged(mojom::WebUiState state) override;
// Returns if `state` has been seen. Consumes all observed states up to the
// point where this state is seen.
void WaitForWebUiState(mojom::WebUiState state);
private:
raw_ptr<Host> host_;
std::deque<mojom::WebUiState> states_;
};
// Observes the state of the WebUI hosted in the glic window.
class CurrentViewListener : public Host::Observer {
public:
explicit CurrentViewListener(Host* host);
~CurrentViewListener() override;
void OnViewChanged(mojom::CurrentView view) override;
// Returns if `state` has been seen. Consumes all observed states up to the
// point where this state is seen.
void WaitForCurrentView(mojom::CurrentView view);
private:
raw_ptr<Host> host_;
std::deque<mojom::CurrentView> views_;
};
template <typename T>
requires std::is_base_of<test::InteractiveGlicTestT<InteractiveBrowserTest>,
T>::value
class GlicApiTestBase : public T {
public:
explicit GlicApiTestBase(std::string_view js_source_path) {
T::embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
&GlicApiTestBase::SorryPageRequestHandler, base::Unretained(this)));
T::embedded_test_server()->RegisterRequestMonitor(
base::BindRepeating(&GlicApiTestBase::OnEmbeddedTestServerHttpRequest,
base::Unretained(this)));
T::add_mock_glic_query_param(
"test",
::testing::UnitTest::GetInstance()->current_test_info()->name());
features_.InitWithFeaturesAndParameters(
/*enabled_features=*/
{
{features::kGlic,
{
{"glic-default-hotkey", "Ctrl+G"},
// Shorten load timeouts.
{features::kGlicPreLoadingTimeMs.name, "20"},
{features::kGlicMinLoadingTimeMs.name, "40"},
}},
},
/*disabled_features=*/
{
features::kGlicWarming,
});
base::CommandLine::ForCurrentProcess()->AppendSwitch(
::switches::kGlicHostLogging);
T::SetGlicPagePath("/glic/browser_tests/test.html");
T::add_mock_glic_query_param("testsrc", js_source_path);
}
~GlicApiTestBase() override = default;
void SetUpOnMainThread() override {
T::host_resolver()->AddRule("a.com", "127.0.0.1");
T::host_resolver()->AddRule("b.com", "127.0.0.1");
NonInteractiveGlicTest::SetUpOnMainThread();
}
void TearDownOnMainThread() override {
if (next_step_required_) {
FAIL() << "Test not finished: call ContinueJsTest()";
}
NonInteractiveGlicTest::TearDownOnMainThread();
}
GlicKeyedService* GetService() {
Profile* profile = T::browser()->profile();
return GlicKeyedServiceFactory::GetGlicKeyedService(profile);
}
Host* GetHost() {
Profile* profile = T::browser()->profile();
return &GlicKeyedServiceFactory::GetGlicKeyedService(profile)->host();
}
// Run the test typescript function. The typescript function must have the
// same name as the current test.
// If the test uses `advanceToNextStep()`, then ContinueJsTest() must be
// called later.
void ExecuteJsTest(ExecuteTestOptions options = {}) {
if (options.wait_for_guest) {
WaitForGuest();
}
content::RenderFrameHost* glic_guest_frame = T::FindGlicGuestMainFrame();
ASSERT_TRUE(glic_guest_frame);
std::string param_json;
base::JSONWriter::Write(options.params, &param_json);
ProcessTestResult(
options,
content::EvalJs(
glic_guest_frame,
base::StrCat(
{"runApiTest(",
base::NumberToString((TestTimeouts::action_max_timeout() * 0.9)
.InMilliseconds()),
",", param_json, ")"})));
}
// Continues test execution if `advanceToNextStep()` was used to return
// control to C++.
void ContinueJsTest(ExecuteTestOptions options = {}) {
ASSERT_TRUE(next_step_required_);
content::RenderFrameHost* glic_guest_frame = T::FindGlicGuestMainFrame();
next_step_required_ = false;
ASSERT_TRUE(glic_guest_frame);
std::string param_json;
base::JSONWriter::Write(options.params, &param_json);
ProcessTestResult(
options,
content::EvalJs(glic_guest_frame,
base::StrCat({"continueApiTest(", param_json, ")"})));
}
void WaitForGuest() {
auto end_time = base::TimeTicks::Now() + base::Seconds(10);
content::RenderFrameHost* frame = nullptr;
while (base::TimeTicks::Now() < end_time) {
// Note: Sometimes the previous guest frame is still around, but it won't
// have the runApiTest function. Loop until both conditions are met.
frame = T::FindGlicGuestMainFrame();
if (frame) {
auto result =
content::EvalJs(frame, {"typeof runApiTest !== 'undefined'"});
if (result.is_ok() && result.ExtractBool()) {
return;
}
}
sleepWithRunLoop(base::Milliseconds(200));
}
FAIL() << "Timed out waiting for guest frame. Guest frame: "
<< (frame ? frame->GetLastCommittedURL().spec() : "not found");
}
void WaitForWebUiState(mojom::WebUiState state) {
WebUIStateListener listener(&T::host());
listener.WaitForWebUiState(state);
}
const std::optional<base::Value>& step_data() const { return step_data_; }
protected:
// Just an error page at a specific /sorry/... URL.
std::unique_ptr<net::test_server::HttpResponse> SorryPageRequestHandler(
const net::test_server::HttpRequest& request) {
if (request.method != net::test_server::METHOD_GET ||
!base::StartsWith(request.relative_url, "/sorry/index.html")) {
return nullptr;
}
auto result = std::make_unique<net::test_server::BasicHttpResponse>();
result->set_code(net::HttpStatusCode::HTTP_OK);
result->set_content_type("text/html");
result->set_content("Sorry!");
return result;
}
void ProcessTestResult(const ExecuteTestOptions& options,
const content::EvalJsResult& result) {
if (options.expect_guest_frame_destroyed) {
ASSERT_THAT(result, content::EvalJsResult::ErrorIs(
testing::HasSubstr("RenderFrame deleted.")));
return;
}
ASSERT_THAT(result, content::EvalJsResult::IsOk());
if (result.is_dict()) {
const base::Value::Dict& dict = result.ExtractDict();
auto* id = dict.Find("id");
if (id && id->is_string() && id->GetString() == "next-step") {
step_data_ = dict.Find("payload")->Clone();
}
next_step_required_ = true;
return;
}
if (!options.should_fail) {
ASSERT_EQ(result, "pass");
} else if (options.should_fail_with_error.empty()) {
ASSERT_NE(result, "pass")
<< "JS step should have failed, but it succeeded";
} else {
ASSERT_EQ(result, options.should_fail_with_error)
<< "JS step should have failed, but it succeeded";
}
}
void AssertAllTestsRegistered(
std::vector<std::string> gunit_test_suite_names) {
#if defined(ADDRESS_SANITIZER) || defined(THREAD_SANITIZER) || \
defined(MEMORY_SANITIZER)
GTEST_SKIP() << "AssertAllTestsRegistered not processed for slow binaries.";
#else
T::RunTestSequence(T::OpenGlicWindow(T::GlicWindowMode::kDetached,
T::GlicInstrumentMode::kNone));
ExecuteJsTest();
ASSERT_TRUE(step_data()->is_list());
::testing::UnitTest* unit_test = ::testing::UnitTest::GetInstance();
std::set<std::string> test_suites;
std::set<std::string> js_test_names, cc_test_names;
for (const auto& test_name : step_data()->GetList()) {
js_test_names.insert(test_name.GetString());
}
for (int i = 0; i < unit_test->total_test_suite_count(); ++i) {
const auto* test_suite = unit_test->GetTestSuite(i);
if (!base::Contains(gunit_test_suite_names,
std::string(test_suite->name()))) {
continue;
}
for (int j = 0; j < test_suite->total_test_count(); ++j) {
std::string name = test_suite->GetTestInfo(j)->name();
// Strips out the test variants suffix.
name = name.substr(0, name.find_last_of('/'));
if (name.starts_with("DISABLED_")) {
cc_test_names.insert(name.substr(9));
} else {
cc_test_names.insert(name);
}
}
}
ASSERT_THAT(js_test_names, testing::IsSubsetOf(cc_test_names))
<< "Test cases in js, but not cc";
ContinueJsTest();
#endif
}
// Records all requests to the embedded test server.
void OnEmbeddedTestServerHttpRequest(
const net::test_server::HttpRequest& request) {
embedded_test_server_requests_.push_back(request);
}
void sleepWithRunLoop(base::TimeDelta sleepDuration) {
base::RunLoop run_loop;
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, run_loop.QuitClosure(), sleepDuration);
run_loop.Run();
}
std::vector<net::test_server::HttpRequest> embedded_test_server_requests_;
bool next_step_required_ = false;
std::optional<base::Value> step_data_;
base::test::ScopedFeatureList features_;
};
using NonInteractiveGlicApiTest = GlicApiTestBase<NonInteractiveGlicTest>;
using InteractiveGlicApiTest = GlicApiTestBase<test::InteractiveGlicTest>;
} // namespace glic
#endif // CHROME_BROWSER_GLIC_TEST_SUPPORT_GLIC_API_TEST_H_