chromium/services/accessibility/features/atp_js_api_test.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.

#include <map>

#include "base/base_paths.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/memory/raw_ptr.h"
#include "base/path_service.h"
#include "base/run_loop.h"
#include "base/strings/stringprintf.h"
#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "build/build_config.h"
#include "services/accessibility/assistive_technology_controller_impl.h"
#include "services/accessibility/fake_service_client.h"
#include "services/accessibility/features/mojo/test/js_test_interface.h"
#include "services/accessibility/os_accessibility_service.h"
#include "services/accessibility/public/mojom/accessibility_service.mojom.h"
#include "services/accessibility/public/mojom/speech_recognition.mojom.h"
#include "services/accessibility/public/mojom/tts.mojom.h"
#include "services/accessibility/public/mojom/user_input.mojom.h"
#include "services/accessibility/public/mojom/user_interface.mojom-shared.h"
#include "services/accessibility/public/mojom/user_interface.mojom.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/accessibility/ax_action_data.h"
#include "ui/accessibility/ax_tree_id.h"
#include "ui/events/event_constants.h"
#include "ui/events/keycodes/keyboard_codes.h"
#include "ui/events/mojom/event_constants.mojom-shared.h"

namespace ax {

// Parent test class for JS APIs implemented for ATP features to consume.
class AtpJSApiTest : public testing::Test {
 public:
  AtpJSApiTest() = default;
  AtpJSApiTest(const AtpJSApiTest&) = delete;
  AtpJSApiTest& operator=(const AtpJSApiTest&) = delete;
  ~AtpJSApiTest() override = default;

  void SetUp() override {
    mojo::PendingReceiver<mojom::AccessibilityService> receiver;
    service_ = std::make_unique<OSAccessibilityService>(std::move(receiver));
    at_controller_ = service_->at_controller_.get();

    client_ = std::make_unique<FakeServiceClient>(service_.get());
    client_->BindAccessibilityServiceClientForTest();
    ASSERT_TRUE(client_->AccessibilityServiceClientIsBound());

    SetUpTestEnvironment();
  }

  void ExecuteJS(const std::string& script) {
    base::RunLoop script_waiter;
    at_controller_->RunScriptForTest(GetATTypeForTest(), script,
                                     script_waiter.QuitClosure());
    script_waiter.Run();
  }

  void WaitForJSTestComplete() {
    // Wait for the test mojom API testComplete method.
    test_waiter_.Run();
  }

  void WaitAtCheckpoint(const std::string& checkpoint_identifier) {
    checkpoint_loops_[checkpoint_identifier].Run();
  }

  void SynchronizeAfterMojoCalls() {
    // TODO(b:332620645): Implement robust synchronization mechanism for atp js
    // tests. This loop here is necessary because the mojo call above that
    // dispatches events needs to send data to a different thread. If the JS
    // code ahead runs without this, it uses the cached callback containing the
    // desktop (which is already computed after a call to
    // chrome.automation.getDesktop), which means it picks up the old tree
    // values without this change.
    base::RunLoop loop;
    loop.RunUntilIdle();
  }

 protected:
  base::test::TaskEnvironment task_environment_;
  std::unique_ptr<FakeServiceClient> client_;
  base::RunLoop test_waiter_;
  std::map<std::string, base::RunLoop> checkpoint_loops_;

 private:
  // The AT type to use, this will inform which APIs are added and available
  // within V8.
  virtual mojom::AssistiveTechnologyType GetATTypeForTest() const = 0;

  // Any additional JS files at these paths will be loaded during
  // SetUpTestEnvironment.
  // Note!!! This should not be alphabetical order, but import order.
  virtual const std::vector<std::string> GetJSFilePathsToLoad() const = 0;

  std::string LoadScriptFromFile(const std::string& file_path) {
    base::FilePath gen_test_data_root;
    base::PathService::Get(base::DIR_GEN_TEST_DATA_ROOT, &gen_test_data_root);
    base::FilePath source_path =
        gen_test_data_root.Append(FILE_PATH_LITERAL(file_path));
    std::string script;
    EXPECT_TRUE(ReadFileToString(source_path, &script))
        << "Could not load script from " << file_path;
    return script;
  }

  void SetUpTestEnvironment() {
    // Turn on an AT.
    std::vector<mojom::AssistiveTechnologyType> enabled_features;
    enabled_features.emplace_back(GetATTypeForTest());
    at_controller_->EnableAssistiveTechnology(enabled_features);

    std::unique_ptr<JSTestInterface> test_interface =
        std::make_unique<JSTestInterface>(
            base::BindLambdaForTesting([this](bool success) {
              EXPECT_TRUE(success) << "Mojo JS was not successful";
              test_waiter_.Quit();
            }),
            base::BindLambdaForTesting(
                [this](const std::string& checkpoint_identifier) {
                  const auto it = checkpoint_loops_.find(checkpoint_identifier);
                  ASSERT_NE(it, checkpoint_loops_.end())
                      << "Javascript code reached a checkpoint: "
                      << checkpoint_identifier
                      << " that c++ is "
                         "not waiting on.";
                  it->second.Quit();
                }));
    at_controller_->AddInterfaceForTest(GetATTypeForTest(),
                                        std::move(test_interface));

    for (const std::string& js_file_path : GetJSFilePathsToLoad()) {
      base::RunLoop test_support_waiter;
      at_controller_->RunScriptForTest(GetATTypeForTest(),
                                       LoadScriptFromFile(js_file_path),
                                       test_support_waiter.QuitClosure());
      test_support_waiter.Run();
    }
  }

  raw_ptr<AssistiveTechnologyControllerImpl, DanglingUntriaged> at_controller_ =
      nullptr;
  std::unique_ptr<OSAccessibilityService> service_;
};

// Tests for generic ChromeEvents.
class ChromeEventTest : public AtpJSApiTest {
 public:
  ChromeEventTest() = default;
  ChromeEventTest(const ChromeEventTest&) = delete;
  ChromeEventTest& operator=(const ChromeEventTest&) = delete;
  ~ChromeEventTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    // Any type is fine.
    return mojom::AssistiveTechnologyType::kChromeVox;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    // TODO(b:266856702): Eventually ATP will load its own JS instead of us
    // doing it in the test. Right now the service doesn't have enough
    // permissions so we load support JS within the test.
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "services/accessibility/features/javascript/chrome_event.js",
    };
  }
};

TEST_F(ChromeEventTest, AddsRemovesAndCallsListeners) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    let listenerAddedCallbackCount = 0;
    const chromeEvent = new ChromeEvent(() => {
      listenerAddedCallbackCount++;
    });

    let firstCallCount = 0;
    const firstListener = (a, b) => {
      if (a !== 'hello' && b !== 'world') {
        remote.testComplete(/*success=*/false);
      }
      firstCallCount++;
    };

    // Add one listener and call it.
    chromeEvent.addListener(firstListener);
    if (listenerAddedCallbackCount !== 1) {
      remote.testComplete(/*success=*/false);
    }
    chromeEvent.callListeners('hello', 'world');
    if (firstCallCount !== 1) {
      remote.testComplete(/*success=*/false);
    }

    let secondCallCount = 0;
    const secondListener = (a, b) => {
      if (a !== 'hello' && b !== 'world') {
        remote.testComplete(/*success=*/false);
      }
      secondCallCount++;
    };

    // Add another listener and call all the listeners.
    chromeEvent.addListener(secondListener);
    if (listenerAddedCallbackCount !== 1) {
      // Listener added callback should only be used once.
      remote.testComplete(/*success=*/false);
    }
    chromeEvent.callListeners('hello', 'world');
    if (firstCallCount !== 2) {
      remote.testComplete(/*success=*/false);
    }
    if (secondCallCount !== 1) {
      remote.testComplete(/*success=*/false);
    }

    // Remove a listener and call the listeners.
    chromeEvent.removeListener(secondListener);
    chromeEvent.callListeners('hello', 'world');
    if (firstCallCount !== 3) {
      remote.testComplete(/*success=*/false);
    }
    if (secondCallCount !== 1) {
      remote.testComplete(/*success=*/false);
    }

    // Remove the first listener and call.
    chromeEvent.removeListener(firstListener);
    chromeEvent.callListeners('no one', 'is listening');
    if (firstCallCount !== 3) {
      remote.testComplete(/*success=*/false);
    }
    if (secondCallCount !== 1) {
      remote.testComplete(/*success=*/false);
    }

    remote.testComplete(/*success=*/true);
  )JS");
  WaitForJSTestComplete();
}

class TtsJSApiTest : public AtpJSApiTest {
 public:
  TtsJSApiTest() = default;
  TtsJSApiTest(const TtsJSApiTest&) = delete;
  TtsJSApiTest& operator=(const TtsJSApiTest&) = delete;
  ~TtsJSApiTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    return mojom::AssistiveTechnologyType::kChromeVox;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    // TODO(b:266856702): Eventually ATP will load its own JS instead of us
    // doing it in the test. Right now the service doesn't have enough
    // permissions so we load support JS within the test.
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "services/accessibility/public/mojom/tts.mojom-lite.js",
        "services/accessibility/features/javascript/tts.js",
    };
  }
};

TEST_F(TtsJSApiTest, TtsGetVoices) {
  // Note: voices are created in FakeServiceClient.
  // TODO(b/266767386): Load test JS from files instead of as strings in C++.
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.tts.getVoices(voices => {
      if (voices.length !== 2) {
        remote.testComplete(/*success=*/false);
        return;
      }
      expectedFirst = {
        "voiceName": "Lyra",
        "eventTypes": [
          "start", "end", "word", "sentence", "marker", "interrupted",
          "cancelled", "error", "pause", "resume"],
        "extensionId": "us_toddler",
        "lang": "en-US",
        "remote":false
      };
      if (JSON.stringify(voices[0]) !== JSON.stringify(expectedFirst)) {
        remote.testComplete(/*success=*/false);
        return;
      }
      expectedSecond = {
        "voiceName": "Juno",
        "eventTypes": ["start", "end"],
        "extensionId": "us_baby",
        "lang": "en-GB",
        "remote":true
      };
      if (JSON.stringify(voices[1]) !== JSON.stringify(expectedSecond)) {
        remote.testComplete(/*success=*/false);
        return;
      }
      remote.testComplete(/*success=*/true);
    });
  )JS");
  WaitForJSTestComplete();
}

// Tests chrome.tts.speak in JS ends up with a call to the
// TTS client in C++, and that callbacks from the TTS client in
// C++ are received as events in JS. Also ensures that ordering
// is consistent: if start is sent before end in C++, it should
// be received before end in JS.
TEST_F(TtsJSApiTest, TtsSpeakWithStartAndEndEvents) {
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [this](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "Hello, world");
        auto start_event = ax::mojom::TtsEvent::New();
        start_event->type = mojom::TtsEventType::kStart;
        auto end_event = ax::mojom::TtsEvent::New();
        end_event->type = mojom::TtsEventType::kEnd;
        client_->SendTtsUtteranceEvent(std::move(start_event));
        client_->SendTtsUtteranceEvent(std::move(end_event));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    let receivedStart = false;
    const onEvent = (ttsEvent) => {
      if (ttsEvent.type === chrome.tts.EventType.END) {
        remote.testComplete(
            /*success=*/receivedStart);
      } else if (ttsEvent.type === chrome.tts.EventType.START) {
        receivedStart = true;
      }
    };
    const options = { onEvent };
    chrome.tts.speak('Hello, world', options);
  )JS");
  WaitForJSTestComplete();
}

TEST_F(TtsJSApiTest, TtsSpeaksNumbers) {
  base::RunLoop waiter;
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [&waiter](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "42");
        waiter.Quit();
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.tts.speak('42');
  )JS");
  waiter.Run();
}

TEST_F(TtsJSApiTest, TtsSpeakPauseResumeStopEvents) {
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [this](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "Green is the loneliest color");
        auto start_event = ax::mojom::TtsEvent::New();
        start_event->type = mojom::TtsEventType::kStart;
        client_->SendTtsUtteranceEvent(std::move(start_event));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    let receivedStart = false;
    let receivedPause = false;
    let receivedResume = false;
    // Start creates a request to pause,
    // pause creates a request to resume,
    // resume creates a request to stop,
    // stop causes interrupted, which ends the test.
    const onEvent = (ttsEvent) => {
      if (ttsEvent.type === chrome.tts.EventType.START) {
        receivedStart = true;
        chrome.tts.pause();
      } else if (ttsEvent.type === chrome.tts.EventType.PAUSE) {
        receivedPause = true;
        chrome.tts.resume();
      } else if (ttsEvent.type === chrome.tts.EventType.RESUME) {
        receivedResume = true;
        chrome.tts.stop();
      } else if (ttsEvent.type === chrome.tts.EventType.INTERRUPTED) {
        remote.testComplete(
            /*success=*/receivedStart && receivedPause && receivedResume);
      } else {
        console.error('Unexpected event type', ttsEvent.type);
        remote.testComplete(
            /*success=*/false);
      }
    };
    const options = { onEvent };
    chrome.tts.speak('Green is the loneliest color', options);
  )JS");
  WaitForJSTestComplete();
}

// Test that parameters can be sent in an event.
TEST_F(TtsJSApiTest, TtsEventPassesParams) {
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [this](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "Hello, world");
        auto start_event = ax::mojom::TtsEvent::New();
        start_event->type = mojom::TtsEventType::kStart;
        start_event->error_message = "Off by one";
        start_event->length = 10;
        start_event->char_index = 5;
        client_->SendTtsUtteranceEvent(std::move(start_event));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const onEvent = (ttsEvent) => {
      if (ttsEvent.type === chrome.tts.EventType.START) {
        let success = ttsEvent.charIndex === 5 &&
          ttsEvent.length === 10 && ttsEvent.errorMessage === 'Off by one';
        remote.testComplete(success);
      }
    };
    const options = { onEvent };
    chrome.tts.speak('Hello, world', options);
  )JS");
  WaitForJSTestComplete();
}

TEST_F(TtsJSApiTest, TtsIsSpeaking) {
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [this](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "Pie in the sky");
        auto start_event = ax::mojom::TtsEvent::New();
        start_event->type = mojom::TtsEventType::kStart;
        client_->SendTtsUtteranceEvent(std::move(start_event));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const onEvent = (ttsEvent) => {
      // Now TTS should be speaking.
      chrome.tts.isSpeaking(secondSpeaking => {
        remote.testComplete(/*success=*/secondSpeaking);
      });
    };
    const options = { onEvent };
    chrome.tts.isSpeaking(isSpeaking => {
      // The first time, TTS should not be speaking.
      if (isSpeaking) {
        remote.testComplete(/*success=*/false);
      }
      chrome.tts.speak('Pie in the sky', options);
    });
  )JS");
  WaitForJSTestComplete();
}

TEST_F(TtsJSApiTest, TtsUtteranceError) {
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [this](const std::string& text, mojom::TtsOptionsPtr options) {
        EXPECT_EQ(text, "No man can kill me");
        auto error_event = ax::mojom::TtsEvent::New();
        error_event->type = mojom::TtsEventType::kError;
        error_event->error_message = "I am no man";
        client_->SendTtsUtteranceEvent(std::move(error_event));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const onEvent = (ttsEvent) => {
      const success = ttsEvent.type == chrome.tts.EventType.ERROR &&
          ttsEvent.errorMessage === 'I am no man';
      remote.testComplete(success);
    };
    const options = { onEvent };
    chrome.tts.isSpeaking(isSpeaking => {
      chrome.tts.speak('No man can kill me', options);
    });
  )JS");
  WaitForJSTestComplete();
}

TEST_F(TtsJSApiTest, DefaultTtsOptions) {
  base::RunLoop waiter;
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [&waiter](const std::string& text, mojom::TtsOptionsPtr options) {
        waiter.Quit();
        EXPECT_EQ(options->pitch, 1.0);
        EXPECT_EQ(options->rate, 1.0);
        EXPECT_EQ(options->volume, 1.0);
        EXPECT_FALSE(options->enqueue);
        EXPECT_FALSE(options->voice_name);
        EXPECT_FALSE(options->engine_id);
        EXPECT_FALSE(options->lang);
        EXPECT_FALSE(options->on_event);
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.tts.speak('You have my ax');
  )JS");

  waiter.Run();
}

TEST_F(TtsJSApiTest, TtsOptions) {
  base::RunLoop waiter;
  client_->SetTtsSpeakCallback(base::BindLambdaForTesting(
      [&waiter](const std::string& text, mojom::TtsOptionsPtr options) {
        waiter.Quit();
        EXPECT_EQ(options->pitch, 0.5);
        EXPECT_EQ(options->rate, 1.5);
        EXPECT_EQ(options->volume, 2.5);
        EXPECT_TRUE(options->enqueue);
        ASSERT_TRUE(options->voice_name);
        EXPECT_EQ(options->voice_name.value(), "Gimli");
        ASSERT_TRUE(options->engine_id);
        EXPECT_EQ(options->engine_id.value(), "us_dwarf");
        ASSERT_TRUE(options->lang);
        EXPECT_EQ(options->lang.value(), "en-NZ");
        EXPECT_TRUE(options->on_event);
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const options = {
      pitch: .5,
      rate: 1.5,
      volume: 2.5,
      enqueue: true,
      engineId: 'us_dwarf',
      lang: 'en-NZ',
      voiceName: 'Gimli',
      onEvent: (ttsEvent) => {},
    };
    chrome.tts.speak('You have my ax', options);
  )JS");

  waiter.Run();
}

class AccessibilityPrivateJSApiTest : public AtpJSApiTest {
 public:
  AccessibilityPrivateJSApiTest() = default;
  AccessibilityPrivateJSApiTest(const AccessibilityPrivateJSApiTest&) = delete;
  AccessibilityPrivateJSApiTest& operator=(
      const AccessibilityPrivateJSApiTest&) = delete;
  ~AccessibilityPrivateJSApiTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    return mojom::AssistiveTechnologyType::kChromeVox;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    // TODO(b:266856702): Eventually ATP will load its own JS instead of us
    // doing it in the test. Right now the service doesn't have enough
    // permissions so we load support JS within the test.
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "mojo/public/mojom/base/time.mojom-lite.js",
        "skia/public/mojom/skcolor.mojom-lite.js",
        "ui/gfx/geometry/mojom/geometry.mojom-lite.js",
        "ui/latency/mojom/latency_info.mojom-lite.js",
        "ui/events/mojom/event_constants.mojom-lite.js",
        "ui/events/mojom/event.mojom-lite.js",
        "services/accessibility/public/mojom/"
        "assistive_technology_type.mojom-lite.js",
        "services/accessibility/public/mojom/user_input.mojom-lite.js",
        "services/accessibility/public/mojom/user_interface.mojom-lite.js",
        "services/accessibility/features/javascript/chrome_event.js",
        "services/accessibility/features/javascript/accessibility_private.js",
    };
  }
};

TEST_F(AccessibilityPrivateJSApiTest, DarkenScreen) {
  base::RunLoop waiter;
  client_->SetDarkenScreenCallback(
      base::BindLambdaForTesting([&waiter](bool darken) {
        waiter.Quit();
        ASSERT_EQ(darken, true);
      }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.darkenScreen(true);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, OpenSettingsSubpage) {
  base::RunLoop waiter;
  client_->SetOpenSettingsSubpageCallback(
      base::BindLambdaForTesting([&waiter](const std::string& subpage) {
        waiter.Quit();
        ASSERT_EQ(subpage, "manageAccessibility/tts");
      }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.openSettingsSubpage('manageAccessibility/tts');
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, ShowConfirmationDialog) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.accessibilityPrivate.showConfirmationDialog(
        'Confirm Order',
        'Your order is: Three samosas, two chai teas, and a side of naan bread',
        'Cancel please, I already ate',
        success => remote.testComplete(success)
    );
  )JS");
  WaitForJSTestComplete();
}

TEST_F(AccessibilityPrivateJSApiTest, SetFocusRings) {
  base::RunLoop waiter;
  client_->SetFocusRingsCallback(base::BindLambdaForTesting([&waiter, this]() {
    waiter.Quit();
    const std::vector<mojom::FocusRingInfoPtr>& focus_rings =
        client_->GetFocusRingsForType(
            ax::mojom::AssistiveTechnologyType::kChromeVox);
    ASSERT_EQ(focus_rings.size(), 1u);
    auto& focus_ring = focus_rings[0];
    EXPECT_EQ(focus_ring->type, mojom::FocusType::kGlow);
    EXPECT_EQ(focus_ring->color, SK_ColorRED);
    ASSERT_EQ(focus_ring->rects.size(), 1u);
    EXPECT_EQ(focus_ring->rects[0], gfx::Rect(50, 100, 200, 300));

    // Optional fields are not set if not passed.
    EXPECT_FALSE(focus_ring->stacking_order.has_value());
    EXPECT_FALSE(focus_ring->background_color.has_value());
    EXPECT_FALSE(focus_ring->secondary_color.has_value());
    EXPECT_FALSE(focus_ring->id.has_value());
  }));
  ExecuteJS(R"JS(
    const focusRingInfo = {
      rects: [{left: 50, top: 100, width: 200, height: 300}],
      type: 'glow',
      color: '#ff0000',
    };
    chrome.accessibilityPrivate.setFocusRings([focusRingInfo],
        chrome.accessibilityPrivate.AssistiveTechnologyType.CHROME_VOX);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, EmptyFocusRings) {
  base::RunLoop waiter;
  client_->SetFocusRingsCallback(base::BindLambdaForTesting([&waiter, this]() {
    waiter.Quit();
    const std::vector<mojom::FocusRingInfoPtr>& focus_rings =
        client_->GetFocusRingsForType(
            ax::mojom::AssistiveTechnologyType::kAutoClick);
    EXPECT_EQ(focus_rings.size(), 0u);
  }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.setFocusRings([],
        chrome.accessibilityPrivate.AssistiveTechnologyType.AUTO_CLICK);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SetFocusRingsOptionalValues) {
  base::RunLoop waiter;
  client_->SetFocusRingsCallback(base::BindLambdaForTesting([&waiter, this]() {
    waiter.Quit();
    const std::vector<mojom::FocusRingInfoPtr>& focus_rings =
        client_->GetFocusRingsForType(
            ax::mojom::AssistiveTechnologyType::kSelectToSpeak);
    ASSERT_EQ(focus_rings.size(), 2u);
    auto& focus_ring1 = focus_rings[0];
    EXPECT_EQ(focus_ring1->type, mojom::FocusType::kSolid);
    EXPECT_EQ(focus_ring1->color, SK_ColorWHITE);
    ASSERT_EQ(focus_ring1->rects.size(), 2u);
    EXPECT_EQ(focus_ring1->rects[0], gfx::Rect(150, 200, 300, 400));
    EXPECT_EQ(focus_ring1->rects[1], gfx::Rect(0, 50, 150, 250));
    ASSERT_TRUE(focus_ring1->stacking_order.has_value());
    EXPECT_EQ(focus_ring1->stacking_order.value(),
              mojom::FocusRingStackingOrder::kAboveAccessibilityBubbles);
    ASSERT_TRUE(focus_ring1->background_color.has_value());
    EXPECT_EQ(focus_ring1->background_color.value(), SK_ColorYELLOW);
    ASSERT_TRUE(focus_ring1->secondary_color.has_value());
    EXPECT_EQ(focus_ring1->secondary_color.value(), SK_ColorMAGENTA);
    ASSERT_TRUE(focus_ring1->id.has_value());
    EXPECT_EQ(focus_ring1->id.value(), "lovelace");

    auto& focus_ring2 = focus_rings[1];
    EXPECT_EQ(focus_ring2->type, mojom::FocusType::kDashed);
    EXPECT_EQ(focus_ring2->color, SK_ColorBLACK);
    ASSERT_EQ(focus_ring2->rects.size(), 1u);
    EXPECT_EQ(focus_ring2->rects[0], gfx::Rect(4, 3, 2, 1));
    ASSERT_TRUE(focus_ring2->stacking_order.has_value());
    EXPECT_EQ(focus_ring2->stacking_order.value(),
              mojom::FocusRingStackingOrder::kBelowAccessibilityBubbles);
    ASSERT_TRUE(focus_ring2->background_color.has_value());
    EXPECT_EQ(focus_ring2->background_color.value(), SK_ColorRED);
    ASSERT_TRUE(focus_ring2->secondary_color.has_value());
    EXPECT_EQ(focus_ring2->secondary_color.value(), SK_ColorCYAN);
    ASSERT_TRUE(focus_ring2->id.has_value());
    EXPECT_EQ(focus_ring2->id.value(), "curie");
  }));
  ExecuteJS(R"JS(
    const stackingOrder = chrome.accessibilityPrivate.FocusRingStackingOrder;
    const focusRingInfo1 = {
      rects: [
        {left: 150, top: 200, width: 300, height: 400},
        {left: 0, top: 50, width: 150, height: 250}
      ],
      type: 'solid',
      color: '#ffffff',
      backgroundColor: '#ffff00',
      // Ensure capitalization doesn't matter.
      secondaryColor: '#FF00ff',
      stackingOrder:
          stackingOrder.ABOVE_ACCESSIBILITY_BUBBLES,
      id: 'lovelace',
    };
    const focusRingInfo2 = {
      rects: [{left: 4, top: 3, width: 2, height: 1}],
      type: 'dashed',
      color: '#000000',
      backgroundColor: 'ff0000',
      secondaryColor: '#00FFFF',
      stackingOrder:
          stackingOrder.BELOW_ACCESSIBILITY_BUBBLES,
      id: 'curie',
    }
    chrome.accessibilityPrivate.setFocusRings(
      [focusRingInfo1, focusRingInfo2],
      chrome.accessibilityPrivate.AssistiveTechnologyType.SELECT_TO_SPEAK);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SetHighlights) {
  base::RunLoop waiter;
  client_->SetHighlightsCallback(base::BindLambdaForTesting(
      [&waiter](const std::vector<gfx::Rect>& rects, SkColor color) {
        waiter.Quit();
        ASSERT_EQ(rects.size(), 2u);
        EXPECT_EQ(rects[0], gfx::Rect(1, 22, 1973, 100));
        EXPECT_EQ(rects[1], gfx::Rect(2, 4, 6, 8));
        EXPECT_EQ(color, SK_ColorGREEN);
      }));
  ExecuteJS(R"JS(
    const rects = [
        {left: 1, top: 22, width: 1973, height: 100},
        {left: 2, top: 4, width: 6, height: 8}
    ];
    chrome.accessibilityPrivate.setHighlights(rects, '#00FF00');
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SetHighlightsEmptyRects) {
  base::RunLoop waiter;
  client_->SetHighlightsCallback(base::BindLambdaForTesting(
      [&waiter](const std::vector<gfx::Rect>& rects, SkColor color) {
        waiter.Quit();
        ASSERT_EQ(rects.size(), 0u);
      }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.setHighlights([], '#FF0000');
  )JS");
  waiter.Run();
}

class AutoclickA11yPrivateJSApiTest : public AtpJSApiTest {
 public:
  AutoclickA11yPrivateJSApiTest() = default;
  AutoclickA11yPrivateJSApiTest(const AutoclickA11yPrivateJSApiTest&) = delete;
  AutoclickA11yPrivateJSApiTest& operator=(
      const AutoclickA11yPrivateJSApiTest&) = delete;
  ~AutoclickA11yPrivateJSApiTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    return mojom::AssistiveTechnologyType::kAutoClick;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "ui/gfx/geometry/mojom/geometry.mojom-lite.js",
        "services/accessibility/public/mojom/autoclick.mojom-lite.js",
        "services/accessibility/features/javascript/chrome_event.js",
        "services/accessibility/features/javascript/accessibility_private.js",
    };
  }
};

TEST_F(AutoclickA11yPrivateJSApiTest, AutoclickApis) {
  base::RunLoop waiter;
  client_->SetScrollableBoundsForPointFoundCallback(
      base::BindLambdaForTesting([&waiter](const gfx::Rect& rect) {
        waiter.Quit();
        ASSERT_EQ(rect, gfx::Rect(2, 4, 6, 8));
      }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.accessibilityPrivate.onScrollableBoundsForPointRequested.addListener(
      (point) => {
        if (point.x !== 42 || point.y !== 84) {
          remote.testComplete(/*success=*/false);
        }
        const rect = {left: 2, top: 4, width: 6, height: 8};
        chrome.accessibilityPrivate.handleScrollableBoundsForPointFound(rect);
    });
    // Exit the JS portion of the test; the callback created above will
    // run after the test C++ executes RequestScrollableBoundsForPoint.
    remote.testComplete(/*success=*/true);
  )JS");
  WaitForJSTestComplete();
  client_->RequestScrollableBoundsForPoint(gfx::Point(42, 84));
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SetVirtualKeyboardVisible) {
  base::RunLoop waiter;
  client_->SetVirtualKeyboardVisibleCallback(
      base::BindLambdaForTesting([&waiter](bool is_visible) {
        waiter.Quit();
        ASSERT_EQ(is_visible, true);
      }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.setVirtualKeyboardVisible(true);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SetVirtualKeyboardInvisible) {
  base::RunLoop waiter;
  client_->SetVirtualKeyboardVisibleCallback(
      base::BindLambdaForTesting([&waiter](bool is_visible) {
        waiter.Quit();
        ASSERT_EQ(is_visible, false);
      }));
  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.setVirtualKeyboardVisible(false);
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, GetDisplayNameForLocale) {
  ExecuteJS(R"JS(
    const locale1 = 'en-US';
    const locale2 = 'es';
    const notreal = '';

    const remote = axtest.mojom.TestBindingInterface.getRemote();

    let displayName = chrome.accessibilityPrivate.getDisplayNameForLocale(
        locale2, locale1);
    if (displayName !== 'Spanish') {
      remote.log('Expected "' + displayName + '" to equal "Spanish"');
      remote.testComplete(/*success=*/false);
    }
    displayName = chrome.accessibilityPrivate.getDisplayNameForLocale(
        locale1, locale1);
    if (!displayName.includes('English')) {
      remote.log('Expected "' + displayName + '" to contain "English"');
      remote.testComplete(/*success=*/false);
    }
    displayName = chrome.accessibilityPrivate.getDisplayNameForLocale(
        locale2, locale2);
    if (displayName !== 'español') {
      remote.log('Expected "' + displayName + '" to equal "español"');
      remote.testComplete(/*success=*/false);
    }
    displayName = chrome.accessibilityPrivate.getDisplayNameForLocale(
        locale2, notreal);
    if (displayName !== '') {
      remote.log('Expected "' + displayName + '" to equal ""');
      remote.testComplete(/*success=*/false);
    }
    displayName = chrome.accessibilityPrivate.getDisplayNameForLocale(
        notreal, locale1);
    if (displayName !== '') {
      remote.log('Expected "' + displayName + '" to equal ""');
      remote.testComplete(/*success=*/false);
    }

    remote.testComplete(/*success=*/ true);
  )JS");

  WaitForJSTestComplete();
}

TEST_F(AccessibilityPrivateJSApiTest,
       SendSyntheticKeyEventForShortcutOrNavigation) {
  base::RunLoop waiter;

  client_->SetSyntheticKeyEventCallback(
      base::BindLambdaForTesting([&waiter, this]() {
        const std::vector<mojom::SyntheticKeyEventPtr>& events =
            client_->GetKeyEvents();
        if (events.size() < 2) {
          return;
        }

        ASSERT_EQ(events.size(), 2u);

        auto& press_event = events[0];
        ASSERT_EQ(press_event->type, ui::mojom::EventType::KEY_PRESSED);
        ASSERT_EQ(press_event->key_data->key_code, ui::VKEY_X);
        // TODO(b/307553499): Update SyntheticKeyEvent to use dom_code and
        // dom_key.
        ASSERT_EQ(press_event->key_data->dom_code, 0u);
        ASSERT_EQ(press_event->key_data->dom_key, 0);
        ASSERT_FALSE(press_event->key_data->is_char);
        ASSERT_EQ(press_event->flags, ui::EF_NONE);

        auto& release_event = events[1];
        ASSERT_EQ(release_event->type, ui::mojom::EventType::KEY_RELEASED);
        ASSERT_EQ(release_event->key_data->key_code, ui::VKEY_X);
        // TODO(b/307553499): Update SyntheticKeyEvent to use dom_code and
        // dom_key.
        ASSERT_EQ(release_event->key_data->dom_code, 0u);
        ASSERT_EQ(release_event->key_data->dom_key, 0);
        ASSERT_FALSE(release_event->key_data->is_char);
        ASSERT_EQ(release_event->flags, ui::EF_NONE);

        waiter.Quit();
      }));

  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.sendSyntheticKeyEvent(
        {type: 'keydown', keyCode: /*X=*/ 88});
    chrome.accessibilityPrivate.sendSyntheticKeyEvent(
        {type: 'keyup', keyCode: /*X=*/ 88});
  )JS");
  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest,
       SendSyntheticKeyEventForShortcutOrNavigationWithModifiers) {
  base::RunLoop waiter;

  client_->SetSyntheticKeyEventCallback(base::BindLambdaForTesting([&waiter,
                                                                    this]() {
    const std::vector<mojom::SyntheticKeyEventPtr>& events =
        client_->GetKeyEvents();
    if (events.size() < 2) {
      return;
    }

    ASSERT_EQ(events.size(), 2u);

    auto& press_event = events[0];
    ASSERT_EQ(press_event->type, ui::mojom::EventType::KEY_PRESSED);
    ASSERT_EQ(press_event->key_data->key_code, ui::VKEY_ESCAPE);
    // TODO(b/307553499): Update SyntheticKeyEvent to use dom_code and  dom_key.
    ASSERT_EQ(press_event->key_data->dom_code, 0u);
    ASSERT_EQ(press_event->key_data->dom_key, 0);
    ASSERT_FALSE(press_event->key_data->is_char);
    ASSERT_EQ(press_event->flags, ui::EF_SHIFT_DOWN | ui::EF_CONTROL_DOWN |
                                      ui::EF_ALT_DOWN | ui::EF_COMMAND_DOWN);

    auto& release_event = events[1];
    ASSERT_EQ(release_event->type, ui::mojom::EventType::KEY_RELEASED);
    ASSERT_EQ(release_event->key_data->key_code, ui::VKEY_ESCAPE);
    // TODO(b/307553499): Update SyntheticKeyEvent to use dom_code and dom_key.
    ASSERT_EQ(release_event->key_data->dom_code, 0u);
    ASSERT_EQ(release_event->key_data->dom_key, 0);
    ASSERT_FALSE(release_event->key_data->is_char);
    ASSERT_EQ(release_event->flags, ui::EF_SHIFT_DOWN | ui::EF_CONTROL_DOWN |
                                        ui::EF_ALT_DOWN | ui::EF_COMMAND_DOWN);

    waiter.Quit();
  }));

  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.sendSyntheticKeyEvent({
      type: 'keydown',
      keyCode: /*ESC=*/ 27,
      modifiers: {
        alt: true,
        ctrl: true,
        search: true,
        shift: true,
      },
    });

    chrome.accessibilityPrivate.sendSyntheticKeyEvent({
      type: 'keyup',
      keyCode: /*ESC=*/ 27,
      modifiers: {
        alt: true,
        ctrl: true,
        search: true,
        shift: true,
      },
    });
  )JS");

  waiter.Run();
}

TEST_F(AccessibilityPrivateJSApiTest, SendSyntheticMouseEvent) {
  base::RunLoop waiter;
  client_->SetSyntheticMouseEventCallback(base::BindLambdaForTesting([&waiter,
                                                                      this]() {
    const auto& events = client_->GetMouseEvents();
    // Wait for all the events to be fired.
    if (events.size() < 6) {
      return;
    }

    // Confirm there are no extra events.
    ASSERT_EQ(events.size(), 6u);

    auto& press_event = events[0];
    EXPECT_EQ(press_event->type, ui::mojom::EventType::MOUSE_PRESSED_EVENT);
    EXPECT_EQ(press_event->point.x(), 20);
    EXPECT_EQ(press_event->point.y(), 30);
    ASSERT_FALSE(press_event->touch_accessibility.has_value());
    ASSERT_TRUE(press_event->mouse_button.has_value());
    EXPECT_EQ(press_event->mouse_button.value(),
              mojom::SyntheticMouseEventButton::kLeft);

    auto& release_event = events[1];
    EXPECT_EQ(release_event->type, ui::mojom::EventType::MOUSE_RELEASED_EVENT);
    EXPECT_EQ(release_event->point.x(), 21);
    EXPECT_EQ(release_event->point.y(), 31);
    ASSERT_TRUE(release_event->touch_accessibility.has_value());
    EXPECT_FALSE(release_event->touch_accessibility.value());
    ASSERT_TRUE(release_event->mouse_button.has_value());
    EXPECT_EQ(release_event->mouse_button.value(),
              mojom::SyntheticMouseEventButton::kMiddle);

    auto& drag_event = events[2];
    EXPECT_EQ(drag_event->type, ui::mojom::EventType::MOUSE_DRAGGED_EVENT);
    EXPECT_EQ(drag_event->point.x(), 22);
    EXPECT_EQ(drag_event->point.y(), 32);
    ASSERT_TRUE(drag_event->touch_accessibility.has_value());
    EXPECT_TRUE(drag_event->touch_accessibility.value());
    ASSERT_TRUE(drag_event->mouse_button.has_value());
    EXPECT_EQ(drag_event->mouse_button.value(),
              mojom::SyntheticMouseEventButton::kRight);

    auto& move_event = events[3];
    EXPECT_EQ(move_event->type, ui::mojom::EventType::MOUSE_MOVED_EVENT);
    EXPECT_EQ(move_event->point.x(), 23);
    EXPECT_EQ(move_event->point.y(), 33);
    ASSERT_FALSE(move_event->touch_accessibility.has_value());
    ASSERT_FALSE(move_event->mouse_button.has_value());

    auto& enter_event = events[4];
    EXPECT_EQ(enter_event->type, ui::mojom::EventType::MOUSE_ENTERED_EVENT);
    EXPECT_EQ(enter_event->point.x(), 24);
    EXPECT_EQ(enter_event->point.y(), 34);
    ASSERT_FALSE(enter_event->touch_accessibility.has_value());
    ASSERT_TRUE(enter_event->mouse_button.has_value());
    EXPECT_EQ(enter_event->mouse_button.value(),
              mojom::SyntheticMouseEventButton::kBack);

    auto& exit_event = events[5];
    EXPECT_EQ(exit_event->type, ui::mojom::EventType::MOUSE_EXITED_EVENT);
    EXPECT_EQ(exit_event->point.x(), 25);
    EXPECT_EQ(exit_event->point.y(), 35);
    ASSERT_FALSE(exit_event->touch_accessibility.has_value());
    ASSERT_TRUE(exit_event->mouse_button.has_value());
    EXPECT_EQ(exit_event->mouse_button.value(),
              mojom::SyntheticMouseEventButton::kForward);

    waiter.Quit();
  }));

  ExecuteJS(R"JS(
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'press',
      x: 20,
      y: 30,
      mouseButton: 'left',
    });
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'release',
      x: 21,
      y: 31,
      mouseButton: 'middle',
      touchAccessibility: false,
    });
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'drag',
      x: 22,
      y: 32,
      mouseButton: 'right',
      touchAccessibility: true,
    });
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'move',
      x: 23,
      y: 33,
    });
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'enter',
      x: 24,
      y: 34,
      mouseButton: 'back',
    });
    chrome.accessibilityPrivate.sendSyntheticMouseEvent({
      type: 'exit',
      x: 25,
      y: 35,
      mouseButton: 'forward',
    });
  )JS");
  waiter.Run();
}

class SpeechRecognitionJSApiTest : public AtpJSApiTest {
 public:
  SpeechRecognitionJSApiTest() = default;
  SpeechRecognitionJSApiTest(const SpeechRecognitionJSApiTest&) = delete;
  SpeechRecognitionJSApiTest& operator=(const SpeechRecognitionJSApiTest&) =
      delete;
  ~SpeechRecognitionJSApiTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    return mojom::AssistiveTechnologyType::kDictation;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    // TODO(b:266856702): Eventually ATP will load its own JS instead of us
    // doing it in the test. Right now the service doesn't have enough
    // permissions so we load support JS within the test.
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "services/accessibility/public/mojom/"
        "assistive_technology_type.mojom-lite.js",
        "services/accessibility/public/mojom/speech_recognition.mojom-lite.js",
        "services/accessibility/features/javascript/chrome_event.js",
        "services/accessibility/features/javascript/speech_recognition.js",
    };
  }
};

TEST_F(SpeechRecognitionJSApiTest, Start) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {
      if (chrome.runtime.lastError) {
        remote.testComplete(/*success=*/false);
      }
      if (type === 'network') {
        remote.testComplete(/*success=*/true);
      } else {
        remote.testComplete(/*success=*/false);
      }
    });
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, StartAndStop) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {
      if (type !== 'network') {
        remote.testComplete(/*success=*/false);
        return;
      }

      chrome.speechRecognitionPrivate.stop(options, () => {
        if (chrome.runtime.lastError) {
          remote.testComplete(/*success=*/false);
        }
        remote.testComplete(/*success=*/true);
      });
    });
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, StopEvent) {
  client_->SetSpeechRecognitionStartCallback(base::BindLambdaForTesting(
      [this]() { client_->SendSpeechRecognitionStopEvent(); }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.speechRecognitionPrivate.onStop.addListener(() => {
      remote.testComplete(/*success=*/true);
    });

    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {});
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, ResultEvent) {
  client_->SetSpeechRecognitionStartCallback(base::BindLambdaForTesting(
      [this]() { client_->SendSpeechRecognitionResultEvent(); }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.speechRecognitionPrivate.onResult.addListener((event) => {
      if (event.transcript === 'Hello world' && event.isFinal) {
        remote.testComplete(/*success=*/true);
      }
    });

    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {});
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, ErrorEvent) {
  client_->SetSpeechRecognitionStartCallback(base::BindLambdaForTesting(
      [this]() { client_->SendSpeechRecognitionErrorEvent(); }));
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.speechRecognitionPrivate.onError.addListener((event) => {
      if (event.message === 'Goodnight world') {
        remote.testComplete(/*success=*/true);
      }
    });

    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {});
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, StartError) {
  client_->SetSpeechRecognitionStartError("Test start error");
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const options = {};
    chrome.speechRecognitionPrivate.start(options, (type) => {
      if (type !== 'network') {
        remote.testComplete(/*success=*/false);
        return;
      }

      const lastError = chrome.runtime.lastError;
      if (lastError && lastError.message === 'Test start error') {
        remote.testComplete(/*success=*/true);
      }
    });
  )JS");
  WaitForJSTestComplete();
}

TEST_F(SpeechRecognitionJSApiTest, StopError) {
  client_->SetSpeechRecognitionStopError("Test stop error");
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const options = {};
    chrome.speechRecognitionPrivate.stop(options, () => {
      const lastError = chrome.runtime.lastError;
      if (lastError && lastError.message === 'Test stop error') {
        remote.testComplete(/*success=*/true);
      }
    });
  )JS");
  WaitForJSTestComplete();
}

class AutomationJSApiTest : public AtpJSApiTest {
 public:
  AutomationJSApiTest() = default;
  AutomationJSApiTest(const AutomationJSApiTest&) = delete;
  AutomationJSApiTest& operator=(const AutomationJSApiTest&) = delete;
  ~AutomationJSApiTest() override = default;

  mojom::AssistiveTechnologyType GetATTypeForTest() const override {
    return mojom::AssistiveTechnologyType::kAutoClick;
  }

  const std::vector<std::string> GetJSFilePathsToLoad() const override {
    // TODO(b:266856702): Eventually ATP will load its own JS instead of us
    // doing it in the test. Right now the service doesn't have enough
    // permissions so we load support JS within the test.
    return std::vector<std::string>{
        "services/accessibility/features/mojo/test/mojom_test_support.js",
        "ui/gfx/geometry/mojom/geometry.mojom-lite.js",
        "mojo/public/mojom/base/unguessable_token.mojom-lite.js",
        "ui/accessibility/ax_enums.mojom-lite.js",
        "ui/accessibility/mojom/ax_tree_id.mojom-lite.js",
        "ui/accessibility/mojom/ax_action_data.mojom-lite.js",
        "services/accessibility/public/mojom/automation_client.mojom-lite.js",
        "services/accessibility/features/javascript/event.js",
        "services/accessibility/features/javascript/chrome_event.js",
        "services/accessibility/features/javascript/automation_internal.js",
        "services/accessibility/features/javascript/automation.js",
    };
  }
};

// Ensures chrome.automation.getDesktop exists and returns something.
// Note that there are no tree updates so properties of the desktop object
// can't yet be calculated.
TEST_F(AutomationJSApiTest, GetDesktop) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
      remote.testComplete(/*success=*/desktop !== null && desktop.isRootNode);
    });
  )JS");
  WaitForJSTestComplete();
}

// Ensures chrome.automation.getFocus|getAccessibilityFocus exist and gets the
// correct node.
TEST_F(AutomationJSApiTest, GetFocuses) {
  std::vector<ui::AXTreeUpdate> updates;
  updates.emplace_back();
  auto& tree_update = updates.back();
  tree_update.has_tree_data = true;
  tree_update.root_id = 1;
  auto& tree_data = tree_update.tree_data;
  tree_data.tree_id = client_->desktop_tree_id();
  tree_data.focus_id = 2;
  tree_update.nodes.emplace_back();
  auto& node_data1 = tree_update.nodes.back();
  node_data1.id = 1;
  node_data1.role = ax::mojom::Role::kDesktop;
  node_data1.child_ids.push_back(2);
  tree_update.nodes.emplace_back();
  auto& node_data2 = tree_update.nodes.back();
  node_data2.id = 2;
  node_data2.role = ax::mojom::Role::kButton;
  std::vector<ui::AXEvent> events;
  client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                   events);

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
        if (!desktop) {
          remote.testComplete(/*success=*/false);
        }
        if (desktop.children.length !== 1 ||
            desktop.firstChild !== desktop.lastChild) {
          remote.testComplete(/*success=*/false);
        }

        // No accessibility focus at the time.
        chrome.automation.getAccessibilityFocus(focus => {
          if (focus) {
            remote.testComplete(/*success=*/false);
          }
        });

        const button = desktop.firstChild;
        if (button.role !== 'button') {
          remote.testComplete(/*success=*/false);
        }
        // Spot check button node.
        if (button.parent !== desktop || button.root !== desktop ||
            button.indexInParent !== 0 || button.children.length !== 0) {
          remote.testComplete(/*success=*/false);
        }
        button.setAccessibilityFocus();
        chrome.automation.getAccessibilityFocus(focus => {
          if (!focus) {
            remote.testComplete(/*success=*/false);
          }
          if (focus !== button) {
            remote.testComplete(/*success=*/false);
          }
          chrome.automation.getFocus(focus => {
            if (!focus) {
              remote.testComplete(/*success=*/false);
            }
           remote.testComplete(/*success=*/focus === button);
        });
      });
    });
  )JS");
  WaitForJSTestComplete();
}

// Ensures that chrome.automation.addTreeChangeObserver() receives updates.
// Note that this test is not to test all possible observer variants, but rather
// to confirm that atp dispatches event to observers.
TEST_F(AutomationJSApiTest, AutomationObservers) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const observer = function(change) {
      if (change.type == 'nodeCreated' && change.target.role == 'button') {
        remote.checkpointReached('ObserverSawFirstTreeUpdate');
      }

      // If we see a textField here, it means that this observer wasn't removed
      // correctly later on.
      if (change.target.role == 'textField') {
        remote.testComplete(/*success=*/false);
      }
    };
    chrome.automation.addTreeChangeObserver("allTreeChanges", observer);
  )JS");

  std::vector<ui::AXTreeUpdate> updates;

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_data.focus_id = 2;
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;
    node_data2.role = ax::mojom::Role::kButton;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  WaitAtCheckpoint("ObserverSawFirstTreeUpdate");

  ExecuteJS(R"JS(
    chrome.automation.getDesktop(desktop => {
      const notAbutton = desktop.firstChild;
      if (notAbutton.role !== 'button') {
        remote.testComplete(/*success=*/false);
      }

      chrome.automation.removeTreeChangeObserver(observer);
      remote.checkpointReached('ObserverRemoved');
    });
  )JS");

  WaitAtCheckpoint("ObserverRemoved");

  updates.clear();

  // Create a second update for the same tree that modifies a node. This update
  // will be ignored by JS because there are no observers left. However, if
  // there are still listeners, they will listen for the text field added node
  // and make this test fail, defined above.
  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_data.focus_id = 2;
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;

    // Only change from the first update.
    node_data2.role = ax::mojom::Role::kTextField;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  ExecuteJS(R"JS(
    remote.testComplete(/*success=*/true);
  )JS");

  WaitForJSTestComplete();
}

// Ensures chrome.automation.setDocumentSelection dispatches a call to the
// AutomationClient interface and the parameters of the action are the correct
// ones.
TEST_F(AutomationJSApiTest, SetDocumentSelection) {
  std::vector<ui::AXTreeUpdate> updates;
  updates.emplace_back();
  auto& tree_update = updates.back();
  tree_update.has_tree_data = true;
  tree_update.root_id = 1;
  auto& tree_data = tree_update.tree_data;
  tree_data.tree_id = client_->desktop_tree_id();
  tree_data.focus_id = 2;
  tree_update.nodes.emplace_back();
  auto& node_data1 = tree_update.nodes.back();
  node_data1.id = 1;
  node_data1.role = ax::mojom::Role::kDesktop;
  node_data1.child_ids.push_back(2);
  node_data1.child_ids.push_back(3);
  tree_update.nodes.emplace_back();
  auto& node_data2 = tree_update.nodes.back();
  node_data2.id = 2;
  node_data2.role = ax::mojom::Role::kStaticText;
  tree_update.nodes.emplace_back();
  auto& node_data3 = tree_update.nodes.back();
  node_data3.id = 3;
  node_data3.role = ax::mojom::Role::kStaticText;

  std::vector<ui::AXEvent> events;
  client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                   events);

  bool perform_action_called = false;
  client_->SetPerformActionCalledCallback(base::BindLambdaForTesting(
      [this, &perform_action_called](const ui::AXActionData& data) {
        perform_action_called = true;
        EXPECT_EQ(data.target_tree_id, client_->desktop_tree_id());
        EXPECT_EQ(data.action, ax::mojom::Action::kSetSelection);
        EXPECT_EQ(data.anchor_node_id, 2);
        EXPECT_EQ(data.anchor_offset, 0);
        EXPECT_EQ(data.focus_node_id, 2);
        EXPECT_EQ(data.focus_offset, 3);
      }));

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
        const unselected_text = desktop.lastChild;

        // This call will not trigger a PerformAction because unselected_text
        // is part of the desktop tree.
        chrome.automation.setDocumentSelection({
          anchorObject: unselected_text,
          focusObject: unselected_text,
          anchorOffset: 1,
          focusOffset: 4,
        });

        const text = desktop.firstChild;

        // TODO(b:330577726): Update test to point to a child tree once ATP
        // supports them.
        // Note: the correct way to call setDocumentSelection in a tree that is
        // a desktop tree is via AutomationNode.setSelection. However, because
        // the goal of this test is to check if the call to the
        // AutomationClient interface is made with the correct parameters, we
        // override the desktop tree ID here so that the API thinks it is not
        // a desktop tree.
        chrome.automation.desktopId_ = 'abcdef';
        chrome.automation.setDocumentSelection({
          anchorObject: text,
          focusObject: text,
          anchorOffset: 0,
          focusOffset: 3,
        });
      remote.testComplete(/*success=*/true);
    });
  )JS");
  WaitForJSTestComplete();
  ASSERT_TRUE(perform_action_called);
}

// Ensures that when a child tree is created, a event is fired on the parent
// tree to indicate that it is finished loading and is
// connected.
TEST_F(AutomationJSApiTest, OnChildTreeEvents) {
  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
      // This event will trigger once the child tree is loaded.
      desktop.addEventListener('childrenChanged', function() {
        const mainTree = chrome.automation.desktopTree;
        if (!mainTree) {
          remote.testComplete(/*success=*/false);
        }
        const childTree = mainTree.childTree;
        if (!childTree) {
          remote.testComplete(/*success=*/false);
        }
        if (childTree.parent !== mainTree) {
          remote.testComplete(/*success=*/false);
        }
        remote.testComplete(/*success=*/true);
      });
    });
      )JS");

  std::vector<ui::AXTreeUpdate> updates;
  const ui::AXTreeID child_tree_id = ui::AXTreeID::CreateNewAXTreeID();

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.AddChildTreeId(child_tree_id);

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }
  updates.clear();

  {
    // Child tree data:
    updates.emplace_back();
    auto& tree_update2 = updates.back();
    tree_update2.has_tree_data = true;
    tree_update2.root_id = 1;
    auto& tree_data2 = tree_update2.tree_data;
    tree_data2.tree_id = child_tree_id;
    tree_update2.nodes.emplace_back();
    auto& node_data2 = tree_update2.nodes.back();
    node_data2.id = 1;
    node_data2.role = ax::mojom::Role::kWebView;
    node_data2.child_ids.push_back(2);
    tree_update2.nodes.emplace_back();
    auto& node_data3 = tree_update2.nodes.back();
    node_data3.id = 2;
    node_data3.role = ax::mojom::Role::kButton;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data2.tree_id, updates, gfx::Point(),
                                     events);
  }

  WaitForJSTestComplete();
}

// Ensures that when nodes are deleted, the js objects are also removed.
TEST_F(AutomationJSApiTest, DeletedNodesAreRemovedFromTree) {
  std::vector<ui::AXTreeUpdate> updates;

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;
    node_data2.role = ax::mojom::Role::kStaticText;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
      if (desktop.children.length !== 1) {
        remote.testComplete(/*success=*/false);
      }
    remote.checkpointReached('JSTreeHasOneChild');
    });
  )JS");

  // Wait until javascript code says it has reached this checkpoint.
  // This is important because there are two threads running here, and it can be
  // the case that the test thread sends all tree updates before js has the
  // chance to see that it had one child, and then later that the child is
  // deleted.
  WaitAtCheckpoint("JSTreeHasOneChild");

  // Delete node:
  {
    updates.clear();
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.node_id_to_clear = 1;
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  SynchronizeAfterMojoCalls();

  ExecuteJS(R"JS(
    chrome.automation.getDesktop(desktop => {
      if (desktop.children.length == 0) {
        remote.testComplete(/*success=*/true);
      }
    });
  )JS");

  WaitForJSTestComplete();
}

// Ensures that when event listeners are added, once the last one is removed,
// automation is disabled and js objects are destroyed.
TEST_F(AutomationJSApiTest, OnAllAutomationEventListenersRemoved) {
  std::vector<ui::AXTreeUpdate> updates;

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    const listener = function() {};
    chrome.automation.getDesktop(desktop => {
      desktop.addEventListener('childrenChanged',       listener);
      remote.checkpointReached('EventListenerAdded');
    });
  )JS");

  WaitAtCheckpoint("EventListenerAdded");

  updates.clear();

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data = tree_update.nodes.back();
    node_data.id = 1;
    node_data.role = ax::mojom::Role::kDesktop;
    node_data.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;
    node_data2.role = ax::mojom::Role::kButton;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  SynchronizeAfterMojoCalls();

  ExecuteJS(R"JS(
    chrome.automation.getDesktop(desktop => {
      desktop.removeEventListener('childrenChanged', listener);
    });
  )JS");

  // TODO(b:332620645): Implement robust synchronization mechanism for atp js
  // This script runs separate from the one above so that the loop of the v8
  // thread runs and dispatches the mojo calls to disable automation (after the
  // last listener is removed). This causes the automation js objects to be
  // reset.

  ExecuteJS(R"JS(
    if (chrome.automation.desktopTree === undefined) {
      remote.testComplete(/*success=*/true);
    }
  )JS");

  WaitForJSTestComplete();

  EXPECT_EQ(client_->num_disable_called(), 1u);
}

// Ensures that the tree is destroyed after a call to the automation
// DispatchTreeDestroyed.
TEST_F(AutomationJSApiTest, OnTreeDestroyedEvent) {
  std::vector<ui::AXTreeUpdate> updates;

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;
    node_data2.role = ax::mojom::Role::kButton;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    let tree_id = '';
    chrome.automation.getDesktop(desktop => {
      // Save tree id for later.
      tree_id = desktop.treeID;
      remote.checkpointReached('TreeLoaded');
    });
  )JS");

  WaitAtCheckpoint("TreeLoaded");

  client_->SendTreeDestroyedEvent(client_->desktop_tree_id());

  SynchronizeAfterMojoCalls();

  ExecuteJS(R"JS(
    const tree = AutomationRootNode.get(tree_id);
    remote.testComplete(/*success=*/!tree);
  )JS");

  WaitForJSTestComplete();
}

// Ensures that automation is notified when an action result is available.
TEST_F(AutomationJSApiTest, OnActionResult) {
  std::vector<ui::AXTreeUpdate> updates;

  {
    updates.emplace_back();
    auto& tree_update = updates.back();
    tree_update.has_tree_data = true;
    tree_update.root_id = 1;
    auto& tree_data = tree_update.tree_data;
    tree_data.tree_id = client_->desktop_tree_id();
    tree_update.nodes.emplace_back();
    auto& node_data1 = tree_update.nodes.back();
    node_data1.id = 1;
    node_data1.role = ax::mojom::Role::kDesktop;
    node_data1.child_ids.push_back(2);
    tree_update.nodes.emplace_back();
    auto& node_data2 = tree_update.nodes.back();
    node_data2.id = 2;
    node_data2.role = ax::mojom::Role::kButton;

    std::vector<ui::AXEvent> events;
    client_->SendAccessibilityEvents(tree_data.tree_id, updates, gfx::Point(),
                                     events);
  }

  client_->SetPerformActionCalledCallback(
      base::BindLambdaForTesting([this](const ui::AXActionData& data) {
        EXPECT_EQ(data.action, ax::mojom::Action::kHitTest);
        EXPECT_EQ(data.target_node_id, 2);
        // TODO(b:333790806): Convert opt_args to AxActionData format. Once that
        // is done, check x and y passed to perform action hit test logic.
        client_->SendActionResult(data, /*result=*/true);
      }));

  ExecuteJS(R"JS(
    const remote = axtest.mojom.TestBindingInterface.getRemote();
    chrome.automation.getDesktop(desktop => {
      const button = desktop.firstChild;
      if (button.role !== 'button') {
        remote.testComplete(/*success=*/false);
      }
      button.hitTestWithReply(10, 10, function() {
        remote.testComplete(/*success=*/true);
      });
    });
  )JS");

  WaitForJSTestComplete();
}

}  // namespace ax