chromium/ui/views/controls/menu/menu_runner_cocoa_unittest.mm

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ui/views/controls/menu/menu_runner_impl_cocoa.h"

#import <Cocoa/Cocoa.h>

#include "base/functional/bind.h"
#include "base/i18n/rtl.h"
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
#include "base/test/test_timeouts.h"
#import "testing/gtest_mac.h"
#include "ui/base/accelerators/accelerator.h"
#include "ui/base/cocoa/menu_controller.h"
#include "ui/base/models/simple_menu_model.h"
#include "ui/events/event_utils.h"
#import "ui/events/test/cocoa_test_event_utils.h"
#include "ui/views/controls/menu/menu_cocoa_watcher_mac.h"
#include "ui/views/controls/menu/menu_runner_impl_adapter.h"
#include "ui/views/test/views_test_base.h"

namespace views::test {
namespace {

constexpr int kTestCommandId = 0;

class TestModel : public ui::SimpleMenuModel {
 public:
  TestModel() : ui::SimpleMenuModel(&delegate_), delegate_(this) {}

  TestModel(const TestModel&) = delete;
  TestModel& operator=(const TestModel&) = delete;

  void set_checked_command(int command) { checked_command_ = command; }

  void set_menu_open_callback(base::OnceClosure callback) {
    menu_open_callback_ = std::move(callback);
  }

 private:
  class Delegate : public ui::SimpleMenuModel::Delegate {
   public:
    explicit Delegate(TestModel* model) : model_(model) {}
    bool IsCommandIdChecked(int command_id) const override {
      return command_id == model_->checked_command_;
    }

    Delegate(const Delegate&) = delete;
    Delegate& operator=(const Delegate&) = delete;

    bool IsCommandIdEnabled(int command_id) const override { return true; }
    void ExecuteCommand(int command_id, int event_flags) override {}

    void OnMenuWillShow(SimpleMenuModel* source) override {
      if (!model_->menu_open_callback_.is_null())
        std::move(model_->menu_open_callback_).Run();
    }

    bool GetAcceleratorForCommandId(
        int command_id,
        ui::Accelerator* accelerator) const override {
      if (command_id == kTestCommandId) {
        *accelerator = ui::Accelerator(ui::VKEY_E, ui::EF_CONTROL_DOWN);
        return true;
      }
      return false;
    }

   private:
    raw_ptr<TestModel> model_;
  };

 private:
  int checked_command_ = -1;
  Delegate delegate_;
  base::OnceClosure menu_open_callback_;
};

enum class MenuType { NATIVE, VIEWS };

std::string MenuTypeToString(::testing::TestParamInfo<MenuType> info) {
  return info.param == MenuType::VIEWS ? "VIEWS_MenuItemView" : "NATIVE_NSMenu";
}

}  // namespace

class MenuRunnerCocoaTest : public ViewsTestBase,
                            public ::testing::WithParamInterface<MenuType> {
 public:
  static constexpr int kWindowHeight = 200;
  static constexpr int kWindowOffset = 100;

  MenuRunnerCocoaTest() : runner_(nullptr), parent_(nullptr) {}

  MenuRunnerCocoaTest(const MenuRunnerCocoaTest&) = delete;
  MenuRunnerCocoaTest& operator=(const MenuRunnerCocoaTest&) = delete;

  ~MenuRunnerCocoaTest() override = default;

  void SetUp() override {
    const int kWindowWidth = 300;
    ViewsTestBase::SetUp();

    menu_ = std::make_unique<TestModel>();
    menu_->AddCheckItem(kTestCommandId, u"Menu Item");

    parent_ = new views::Widget();
    parent_->Init(CreateParams(Widget::InitParams::TYPE_WINDOW_FRAMELESS));
    parent_->SetBounds(
        gfx::Rect(kWindowOffset, kWindowOffset, kWindowWidth, kWindowHeight));
    parent_->Show();

    native_view_subview_count_ =
        [[parent_->GetNativeView().GetNativeNSView() subviews] count];

    base::RepeatingClosure on_close = base::BindRepeating(
        &MenuRunnerCocoaTest::MenuCloseCallback, base::Unretained(this));
    if (GetParam() == MenuType::NATIVE)
      runner_ = new internal::MenuRunnerImplCocoa(menu_.get(), on_close);
    else
      runner_ = new internal::MenuRunnerImplAdapter(menu_.get(), on_close);
    EXPECT_FALSE(runner_->IsRunning());
  }

  void TearDown() override {
    EXPECT_EQ(native_view_subview_count_,
              [[parent_->GetNativeView().GetNativeNSView() subviews] count]);

    if (runner_) {
      runner_.ExtractAsDangling()->Release();
    }

    // Clean up for tests that set the notification filter.
    MenuCocoaWatcherMac::SetNotificationFilterForTesting(
        MacNotificationFilter::DontIgnoreNotifications);

    parent_.ExtractAsDangling()->CloseNow();
    ViewsTestBase::TearDown();
  }

  int IsAsync() const { return GetParam() == MenuType::VIEWS; }

  // Runs the menu after registering |callback| as the menu open callback.
  void RunMenu(base::OnceClosure callback) {
    MenuCocoaWatcherMac::SetNotificationFilterForTesting(
        MacNotificationFilter::IgnoreAllNotifications);

    base::OnceClosure wrapped_callback = base::BindOnce(
        [](base::OnceClosure cb) {
          // Ignore app activation notifications while the test is running (all
          // others are OK).
          MenuCocoaWatcherMac::SetNotificationFilterForTesting(
              MacNotificationFilter::IgnoreWorkspaceNotifications);
          std::move(cb).Run();
        },
        std::move(callback));
    if (IsAsync()) {
      // Cancelling an async menu under MenuControllerCocoa::OpenMenuImpl()
      // (which invokes WillShowMenu()) will cause a UAF when that same function
      // tries to show the menu. So post a task instead.
      base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
          FROM_HERE, std::move(wrapped_callback));
    } else {
      menu_->set_menu_open_callback(
          base::BindOnce(&MenuRunnerCocoaTest::RunMenuWrapperCallback,
                         base::Unretained(this), std::move(wrapped_callback)));
    }

    runner_->RunMenuAt(parent_, nullptr, gfx::Rect(),
                       MenuAnchorPosition::kTopLeft, MenuRunner::CONTEXT_MENU,
                       nullptr);
    MaybeRunAsync();
  }

  void MenuCancelCallback() {
    runner_->Cancel();
    if (IsAsync()) {
      // Async menus report their cancellation immediately.
      EXPECT_FALSE(runner_->IsRunning());
    } else {
      // For a synchronous menu, MenuRunner::IsRunning() should return true
      // immediately after MenuRunner::Cancel() since the menu message loop has
      // not yet terminated. It has only been marked for termination.
      EXPECT_TRUE(runner_->IsRunning());
    }
  }

  void MenuDeleteCallback() {
    runner_.ExtractAsDangling()->Release();
    // Deleting an async menu intentionally does not invoke MenuCloseCallback().
    // (The callback is typically a method on something in the process of being
    // destroyed). So invoke QuitAsyncRunLoop() here as well.
    QuitAsyncRunLoop();
  }

  NSMenu* GetNativeNSMenu() {
    if (GetParam() == MenuType::VIEWS)
      return nil;

    internal::MenuRunnerImplCocoa* cocoa_runner =
        static_cast<internal::MenuRunnerImplCocoa*>(runner_);
    return [cocoa_runner->menu_controller_ menu];
  }

  void ModelDeleteThenSelectItemCallback() {
    // AppKit may retain a reference to the NSMenu.
    NSMenu* native_menu = GetNativeNSMenu();

    // A View showing a menu typically owns a MenuRunner unique_ptr, which will
    // will be destroyed (releasing the MenuRunnerImpl) alongside the MenuModel.
    runner_.ExtractAsDangling()->Release();
    menu_ = nullptr;

    // The menu is closing (yet "alive"), but the model is destroyed. The user
    // may have already made an event to select an item in the menu. This
    // doesn't bother views menus (see MenuRunnerImpl::empty_delegate_) but
    // Cocoa menu items are refcounted and have access to a raw weak pointer in
    // the MenuController.
    if (GetParam() == MenuType::VIEWS) {
      QuitAsyncRunLoop();
      return;
    }

    EXPECT_TRUE(native_menu);

    // Simulate clicking the item using its accelerator.
    NSEvent* accelerator = cocoa_test_event_utils::KeyEventWithKeyCode(
        'e', 'e', NSEventTypeKeyDown, NSEventModifierFlagCommand);
    [native_menu performKeyEquivalent:accelerator];
  }

  void MenuCancelAndDeleteCallback() {
    runner_->Cancel();
    // Release cause the runner to delete itself.
    runner_.ExtractAsDangling()->Release();
  }

 protected:
  std::unique_ptr<TestModel> menu_;
  raw_ptr<internal::MenuRunnerImplInterface> runner_ = nullptr;
  raw_ptr<views::Widget> parent_ = nullptr;
  NSUInteger native_view_subview_count_ = 0;
  int menu_close_count_ = 0;

 private:
  void RunMenuWrapperCallback(base::OnceClosure callback) {
    EXPECT_TRUE(runner_->IsRunning());
    std::move(callback).Run();
  }

  // Run a nested run loop so that async and sync menus can be tested the
  // same way.
  void MaybeRunAsync() {
    if (!IsAsync())
      return;

    base::RunLoop run_loop;
    quit_closure_ = run_loop.QuitClosure();
    base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask(
        FROM_HERE, quit_closure_, TestTimeouts::action_timeout());
    run_loop.Run();

    // |quit_closure_| should be run by QuitAsyncRunLoop(), not the timeout.
    EXPECT_TRUE(quit_closure_.is_null());
  }

  void QuitAsyncRunLoop() {
    if (!IsAsync()) {
      EXPECT_TRUE(quit_closure_.is_null());
      return;
    }
    ASSERT_FALSE(quit_closure_.is_null());
    quit_closure_.Run();
    quit_closure_.Reset();
  }

  void MenuCloseCallback() {
    ++menu_close_count_;
    QuitAsyncRunLoop();
  }

  base::RepeatingClosure quit_closure_;
};

TEST_P(MenuRunnerCocoaTest, RunMenuAndCancel) {
  base::TimeTicks min_time = ui::EventTimeForNow();

  RunMenu(base::BindOnce(&MenuRunnerCocoaTest::MenuCancelCallback,
                         base::Unretained(this)));

  EXPECT_EQ(1, menu_close_count_);
  EXPECT_FALSE(runner_->IsRunning());

  if (GetParam() == MenuType::VIEWS) {
    // MenuItemView's MenuRunnerImpl gets the closing time from
    // MenuControllerCocoa:: closing_event_time(). This is is reset on show, but
    // only updated when an event closes the menu -- not a cancellation.
    EXPECT_EQ(runner_->GetClosingEventTime(), base::TimeTicks());
  } else {
    EXPECT_GE(runner_->GetClosingEventTime(), min_time);
  }
  EXPECT_LE(runner_->GetClosingEventTime(), ui::EventTimeForNow());

  // Cancel again.
  runner_->Cancel();
  EXPECT_FALSE(runner_->IsRunning());
  EXPECT_EQ(1, menu_close_count_);
}

TEST_P(MenuRunnerCocoaTest, RunMenuAndDelete) {
  RunMenu(base::BindOnce(&MenuRunnerCocoaTest::MenuDeleteCallback,
                         base::Unretained(this)));
  // Note the close callback is NOT invoked for deleted menus.
  EXPECT_EQ(0, menu_close_count_);
}

// Tests a potential lifetime issue using the Cocoa MenuController, which has a
// weak reference to the model.
TEST_P(MenuRunnerCocoaTest, RunMenuAndDeleteThenSelectItem) {
  RunMenu(
      base::BindOnce(&MenuRunnerCocoaTest::ModelDeleteThenSelectItemCallback,
                     base::Unretained(this)));
  EXPECT_EQ(0, menu_close_count_);
}

// Ensure a menu can be safely released immediately after a call to Cancel() in
// the same run loop iteration.
TEST_P(MenuRunnerCocoaTest, DestroyAfterCanceling) {
  RunMenu(base::BindOnce(&MenuRunnerCocoaTest::MenuCancelAndDeleteCallback,
                         base::Unretained(this)));

  if (IsAsync()) {
    EXPECT_EQ(1, menu_close_count_);
  } else {
    // For a synchronous menu, the deletion happens before the cancel can be
    // processed, so the close callback will not be invoked.
    EXPECT_EQ(0, menu_close_count_);
  }
}

TEST_P(MenuRunnerCocoaTest, RunMenuTwice) {
  for (int i = 0; i < 2; ++i) {
    RunMenu(base::BindOnce(&MenuRunnerCocoaTest::MenuCancelCallback,
                           base::Unretained(this)));
    EXPECT_FALSE(runner_->IsRunning());
    EXPECT_EQ(i + 1, menu_close_count_);
  }
}

TEST_P(MenuRunnerCocoaTest, CancelWithoutRunning) {
  runner_->Cancel();
  EXPECT_FALSE(runner_->IsRunning());
  EXPECT_EQ(base::TimeTicks(), runner_->GetClosingEventTime());
  EXPECT_EQ(0, menu_close_count_);
}

TEST_P(MenuRunnerCocoaTest, DeleteWithoutRunning) {
  runner_.ExtractAsDangling()->Release();
  EXPECT_EQ(0, menu_close_count_);
}

INSTANTIATE_TEST_SUITE_P(,
                         MenuRunnerCocoaTest,
                         ::testing::Values(MenuType::NATIVE, MenuType::VIEWS),
                         &MenuTypeToString);

}  // namespace views::test