| // 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 <memory> |
| #include <optional> |
| #include <string> |
| |
| #include "base/functional/bind.h" |
| #include "base/functional/callback_forward.h" |
| #include "base/json/json_reader.h" |
| #include "base/location.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/run_loop.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/strings/to_string.h" |
| #include "base/task/sequenced_task_runner.h" |
| #include "base/test/task_environment.h" |
| #include "base/values.h" |
| #include "mojo/public/cpp/bindings/associated_receiver.h" |
| #include "mojo/public/cpp/bindings/associated_remote.h" |
| #include "mojo/public/cpp/bindings/pending_associated_receiver.h" |
| #include "mojo/public/cpp/bindings/pending_associated_remote.h" |
| #include "mojo/public/cpp/bindings/pending_remote.h" |
| #include "mojo/public/cpp/bindings/remote.h" |
| #include "mojo/public/cpp/bindings/self_owned_receiver.h" |
| #include "services/accessibility/features/v8_manager.h" |
| #include "services/accessibility/public/mojom/accessibility_service.mojom.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "third_party/blink/public/mojom/devtools/devtools_agent.mojom.h" |
| #include "third_party/inspector_protocol/crdtp/json.h" |
| |
| namespace ax { |
| |
| namespace { |
| |
| crdtp::span<uint8_t> ToSpan(const std::string& string) { |
| return crdtp::span<uint8_t>(reinterpret_cast<const uint8_t*>(string.data()), |
| string.size()); |
| } |
| |
| // Class that pretends to be a devtools session host so we can check our |
| // evaluations. |
| class FakeDevToolsSessionHost : public blink::mojom::DevToolsSessionHost { |
| public: |
| explicit FakeDevToolsSessionHost( |
| base::RepeatingCallback<void(int)> expectation_complete_cb) |
| : expectation_complete_cb_(std::move(expectation_complete_cb)) {} |
| ~FakeDevToolsSessionHost() override { |
| // Ensure all expectations have been checked. |
| std::string remaining_expectations = "["; |
| for (auto& it : expectations_) { |
| remaining_expectations += " " + base::ToString(it.first) + " "; |
| } |
| |
| remaining_expectations += "]"; |
| EXPECT_TRUE(expectations_.empty()) |
| << "Failed expectation call ids: " << remaining_expectations; |
| } |
| // blink::mojom::DevToolsSessionHost implementation. |
| void DispatchProtocolResponse( |
| blink::mojom::DevToolsMessagePtr message, |
| int call_id, |
| blink::mojom::DevToolsSessionStatePtr updates) override { |
| // Convert message bytes to span. |
| crdtp::span<uint8_t> message_span(message->data.data(), |
| message->data.size()); |
| // Convert binary to json. |
| std::string message_str; |
| crdtp::json::ConvertCBORToJSON(message_span, &message_str); |
| // Read json into object. |
| auto json_parsed = base::JSONReader::Read(message_str); |
| // This json should be a valid dict. |
| EXPECT_TRUE(json_parsed.has_value() && json_parsed->is_dict()); |
| // See |
| // https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject |
| // for details about json structure. |
| std::string actual_type = |
| *json_parsed->GetDict().FindStringByDottedPath("result.result.type"); |
| auto actual_value = |
| json_parsed->GetDict().ExtractByDottedPath("result.result.value"); |
| RunExpectation(call_id, actual_type, std::move(actual_value)); |
| } |
| |
| void DispatchProtocolNotification( |
| blink::mojom::DevToolsMessagePtr message, |
| blink::mojom::DevToolsSessionStatePtr updates) override {} |
| mojo::AssociatedReceiver<blink::mojom::DevToolsSessionHost> receiver{this}; |
| |
| void ExpectEvalResult(int call_id, |
| std::string type, |
| std::optional<base::Value> value = std::nullopt) { |
| expectations_[call_id] = (base::BindOnce( |
| [](std::string expected_type, std::optional<base::Value> expected_value, |
| std::string actual_type, std::optional<base::Value> actual_value) { |
| EXPECT_EQ(expected_type, actual_type); |
| EXPECT_EQ(expected_value.has_value(), actual_value.has_value()); |
| // If there are values to check. |
| if (actual_value.has_value()) { |
| EXPECT_EQ(expected_value->type(), actual_value->type()); |
| switch (actual_value->type()) { |
| case base::Value::Type::BOOLEAN: |
| EXPECT_EQ(expected_value->GetBool(), actual_value->GetBool()); |
| break; |
| case base::Value::Type::STRING: |
| EXPECT_EQ(expected_value->GetString(), |
| actual_value->GetString()); |
| break; |
| case base::Value::Type::INTEGER: |
| EXPECT_EQ(expected_value->GetInt(), actual_value->GetInt()); |
| break; |
| case base::Value::Type::DOUBLE: |
| EXPECT_EQ(expected_value->GetDouble(), |
| actual_value->GetDouble()); |
| break; |
| default: |
| // Non primitive values shouldn't be checked for in these tests. |
| break; |
| } |
| } |
| }, |
| type, std::move(value))); |
| } |
| |
| private: |
| void RunExpectation(int call_id, |
| std::string actual_type, |
| std::optional<base::Value> actual_value) { |
| // Run expectation against the call id. |
| if (auto it = expectations_.find(call_id); it != expectations_.end()) { |
| std::move(it->second).Run(actual_type, std::move(actual_value)); |
| } |
| expectations_.erase(call_id); |
| expectation_complete_cb_.Run(expectations_.size()); |
| } |
| |
| std::map<int, |
| base::OnceCallback<void(std::string, std::optional<base::Value>)>> |
| expectations_; |
| |
| const base::RepeatingCallback<void(int remaining_expectations)> |
| expectation_complete_cb_; |
| }; |
| |
| // Class that pretends to be an accessibility service for the sake of setting up |
| // the associated mojo pipes that devtools uses. |
| class FakeAccessibilityService : public mojom::AccessibilityService { |
| public: |
| FakeAccessibilityService() : v8_manager_(std::make_unique<V8Manager>()) { |
| v8_manager_->FinishContextSetUp(); |
| } |
| |
| void BindAccessibilityServiceClient( |
| mojo::PendingRemote<mojom::AccessibilityServiceClient> |
| accessibility_client_remote) override {} |
| |
| void BindAssistiveTechnologyController( |
| mojo::PendingReceiver<mojom::AssistiveTechnologyController> |
| at_at_controller_receiver, |
| const std::vector<mojom::AssistiveTechnologyType>& enabled_features) |
| override {} |
| |
| void ConnectDevToolsAgent( |
| ::mojo::PendingAssociatedReceiver<::blink::mojom::DevToolsAgent> agent, |
| ::ax::mojom::AssistiveTechnologyType type) override { |
| v8_manager_->ConnectDevToolsAgent(std::move(agent)); |
| connect_dev_tools_count_++; |
| } |
| |
| int GetConnectionAttemptCount() { return connect_dev_tools_count_; } |
| std::unique_ptr<V8Manager> v8_manager_; |
| |
| private: |
| int connect_dev_tools_count_ = 0; |
| }; |
| } // namespace |
| |
| // Unit test for setting up and interacting with ATP devtools. |
| class OSDevToolsTest : public testing::Test { |
| public: |
| OSDevToolsTest() |
| : fake_session_host_( |
| base::BindRepeating(&OSDevToolsTest::ExpectationComplete, |
| base::Unretained(this))) {} |
| OSDevToolsTest(const OSDevToolsTest&) = delete; |
| OSDevToolsTest& operator=(const OSDevToolsTest&) = delete; |
| ~OSDevToolsTest() override = default; |
| |
| void SetUp() override { |
| BindingsIsolateHolder::InitializeV8(); |
| |
| // Set up the fake service. |
| mojo::PendingRemote<mojom::AccessibilityService> service_remote_p; |
| auto fake_service = std::make_unique<FakeAccessibilityService>(); |
| fake_service_ = fake_service.get(); |
| mojo::MakeSelfOwnedReceiver( |
| std::move(fake_service), |
| service_remote_p.InitWithNewPipeAndPassReceiver()); |
| service_remote_.Bind(std::move(service_remote_p)); |
| ConnectDevToolsAgent(); |
| AttachSession(); |
| } |
| |
| void EvalJS(int call_id, std::string script, bool use_io = false) { |
| static constexpr char kCmdTemplate[] = R"JSON( |
| { |
| "id": %d, |
| "method": "Runtime.evaluate", |
| "params": { |
| "expression": "%s", |
| "contextId": 1 |
| } |
| } |
| )JSON"; |
| std::string to_eval = |
| base::StringPrintf(kCmdTemplate, call_id, script.c_str()); |
| EvalCommand(call_id, "Runtime.evaluate", to_eval, use_io); |
| } |
| |
| void EvalCommand(int call_id, |
| std::string command_name, |
| std::string command, |
| bool use_io = false) { |
| base::span<const uint8_t> message; |
| std::vector<uint8_t> cbor; |
| // JSON -> CBOR. |
| crdtp::Status status = |
| crdtp::json::ConvertJSONToCBOR(ToSpan(command), &cbor); |
| CHECK(status.ok()) << status.Message(); |
| message = base::span<const uint8_t>(cbor.data(), cbor.size()); |
| if (!use_io) { |
| session_remote_->DispatchProtocolCommand(call_id, command_name, message); |
| } else { |
| io_session_remote_->DispatchProtocolCommand(call_id, command_name, |
| message); |
| } |
| } |
| |
| void ExpectEvalResult(int call_id, |
| std::string type, |
| std::optional<base::Value> value = std::nullopt) { |
| fake_session_host_.ExpectEvalResult(call_id, type, std::move(value)); |
| } |
| |
| void OnExpectationsComplete(base::OnceClosure callback) { |
| expectations_complete_callbacks_.push_back(std::move(callback)); |
| } |
| |
| void ConnectDevToolsAgent() { |
| // Connect the agent. |
| mojo::PendingAssociatedRemote<blink::mojom::DevToolsAgent> agent_remote_p; |
| service_remote_->ConnectDevToolsAgent( |
| agent_remote_p.InitWithNewEndpointAndPassReceiver(), |
| ax::mojom::AssistiveTechnologyType::kChromeVox); |
| // Wait for connect devtools agent to be called. |
| service_remote_.FlushForTesting(); |
| EXPECT_EQ(fake_service_->GetConnectionAttemptCount(), 1); |
| agent_remote.Bind(std::move(agent_remote_p)); |
| } |
| |
| void AttachSession() { |
| // Attach the session. |
| agent_remote->AttachDevToolsSession( |
| fake_session_host_.receiver.BindNewEndpointAndPassRemote(), |
| session_remote_.BindNewEndpointAndPassReceiver(), |
| io_session_remote_.BindNewPipeAndPassReceiver(), |
| std::move(reattach_session_state_), /*client_expects_binary*/ true, |
| /*client_is_trusted=*/true, /*session_id=*/"session", |
| /*session_waits_for_debugger=*/false); |
| } |
| |
| // The agent lives for as long as the manager does. It goes down when an AT is |
| // disabled, which is accomplished by deleting the v8_manager. |
| void DisconnectDevToolsAgent() { fake_service_->v8_manager_.reset(); } |
| |
| protected: |
| mojo::Remote<mojom::AccessibilityService> service_remote_; |
| // Associated remotes must be passed through an existing mojo connection. |
| // Simulate the crossing the service boundary with a fake accessibility |
| // service. |
| raw_ptr<FakeAccessibilityService> fake_service_; |
| // Session Host |
| FakeDevToolsSessionHost fake_session_host_; |
| // Session Remote |
| mojo::AssociatedRemote<blink::mojom::DevToolsSession> session_remote_; |
| // IO Session Remote |
| mojo::Remote<blink::mojom::DevToolsSession> io_session_remote_; |
| // Agent Remote |
| mojo::AssociatedRemote<blink::mojom::DevToolsAgent> agent_remote; |
| // Session State |
| blink::mojom::DevToolsSessionStatePtr reattach_session_state_; |
| |
| private: |
| void ExpectationComplete(int remaining_expecations) { |
| if (remaining_expecations == 0) { |
| for (auto& cb : expectations_complete_callbacks_) { |
| std::move(cb).Run(); |
| } |
| expectations_complete_callbacks_.clear(); |
| } |
| } |
| |
| base::test::TaskEnvironment task_environment_; |
| std::vector<base::OnceClosure> expectations_complete_callbacks_; |
| }; |
| |
| TEST_F(OSDevToolsTest, ConnectAndEvalJS) { |
| // Set expectations. |
| ExpectEvalResult(1, "undefined"); |
| ExpectEvalResult(2, "undefined"); |
| ExpectEvalResult(3, "undefined"); |
| ExpectEvalResult(4, "number", base::Value(9)); |
| base::RunLoop expectation_waiter; |
| OnExpectationsComplete( |
| base::BindOnce([]() { LOG(INFO) << "Expectations complete."; })); |
| OnExpectationsComplete(expectation_waiter.QuitClosure()); |
| // Test some evaluations. |
| EvalJS(1, "atpconsole.log('Hello World!');"); |
| EvalJS(2, "console.log('Hello world!');"); |
| // Send a commands via IO. |
| EvalJS(3, "const x = 9;", true); |
| EvalJS(4, "x;", true); |
| LOG(INFO) << "Waiting for expectations to complete..."; |
| expectation_waiter.Run(); |
| // Disconnect the session. |
| session_remote_.reset(); |
| // Flush the service remote since it is the associated pipe and will send the |
| // disconnect message. |
| service_remote_.FlushForTesting(); |
| // disconnect the io session |
| io_session_remote_.reset(); |
| // Post a task to the main runner to make sure IOSession is deleted before the |
| // test ends. |
| auto tr = base::SequencedTaskRunner::GetCurrentDefault(); |
| base::RunLoop loop; |
| tr->PostTask(FROM_HERE, loop.QuitClosure()); |
| loop.Run(); |
| // Send the disconnect message to delete the io session. |
| // Tear down the agent. |
| DisconnectDevToolsAgent(); |
| } |
| |
| // This test checks some evaluations and ensures there are no crashes if the |
| // agent is deleted before the session. |
| TEST_F(OSDevToolsTest, DisableATWhileSessionConnected) { |
| // Prepare expecations. |
| ExpectEvalResult(1, "string", base::Value("Hello World!")); |
| base::RunLoop expectation_waiter; |
| OnExpectationsComplete( |
| base::BindOnce([]() { LOG(INFO) << "Expectations complete."; })); |
| OnExpectationsComplete(expectation_waiter.QuitClosure()); |
| // Test some evaluations. |
| EvalJS(1, "'Hello' + ' World!'"); |
| // Wait for expecations to complete. |
| LOG(INFO) << "Waiting for expectations to complete..."; |
| expectation_waiter.Run(); |
| // Tear down the agent without disconnecting the session first. |
| DisconnectDevToolsAgent(); |
| // Disconnect the io session. |
| io_session_remote_.reset(); |
| // Post a task to the main runner to make sure IOSession is deleted before the |
| // test ends. |
| auto tr = base::SequencedTaskRunner::GetCurrentDefault(); |
| base::RunLoop loop; |
| tr->PostTask(FROM_HERE, loop.QuitClosure()); |
| loop.Run(); |
| // Flush the service remote since it is the associated pipe and will send the |
| // disconnect message for agent. |
| service_remote_.FlushForTesting(); |
| } |
| |
| } // namespace ax |