chromium/services/accessibility/features/devtools/os_devtools_unittest.cc

// 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.

#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif

#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