chromium/ui/base/test/cocoa_helper.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/base/test/cocoa_helper.h"

#include <objc/message.h>
#include <objc/runtime.h>

#include <set>
#include <vector>

#include "base/debug/debugger.h"
#include "base/logging.h"
#include "base/stl_util.h"
#include "base/strings/sys_string_conversions.h"
#include "base/test/mock_chrome_application_mac.h"
#include "base/test/test_timeouts.h"

@implementation CocoaTestHelperWindow

@synthesize pretendIsKeyWindow = _pretendIsKeyWindow;
@synthesize pretendIsOnActiveSpace = _pretendIsOnActiveSpace;
@synthesize pretendFullKeyboardAccessIsEnabled =
    _pretendFullKeyboardAccessIsEnabled;
@synthesize useDefaultConstraints = _useDefaultConstraints;

- (instancetype)initWithContentRect:(NSRect)contentRect {
  self = [super initWithContentRect:contentRect
                          styleMask:NSWindowStyleMaskBorderless
                            backing:NSBackingStoreBuffered
                              defer:NO];
  if (self) {
    _useDefaultConstraints = YES;
    _pretendIsOnActiveSpace = YES;
    self.releasedWhenClosed = NO;
  }
  return self;
}

- (instancetype)init {
  return [self initWithContentRect:NSMakeRect(0, 0, 800, 600)];
}

// Enables users to write debugging code to help diagnose failures to close
// CocoaTestHelperWindow. Debugging code is not usually committed in Chromium,
// but because it's difficult to correctly override retain/release in ARC, this
// is left in.
#if 0

+ (void)initialize {
  if (self == [CocoaTestHelperWindow self]) {
    Class test_class = [CocoaTestHelperWindow class];

    Method method = class_getInstanceMethod(test_class, @selector(debugRetain));
    ASSERT_TRUE(method);
    ASSERT_TRUE(class_addMethod(test_class, sel_registerName("retain"),
                                method_getImplementation(method),
                                method_getTypeEncoding(method)));

    method = class_getInstanceMethod(test_class, @selector(debugRelease));
    ASSERT_TRUE(method);
    ASSERT_TRUE(class_addMethod(test_class, sel_registerName("release"),
                                method_getImplementation(method),
                                method_getTypeEncoding(method)));
  }
}

- (void)dealloc {
  // Insert debugging code here.
}

- (instancetype)debugRetain {
  // Insert debugging code here.

  struct objc_super mySuper = {.receiver = self,
                               .super_class = [self superclass]};
  using retainSendSuper = id (*)(struct objc_super*, SEL);
  retainSendSuper sendSuper =
      reinterpret_cast<retainSendSuper>(objc_msgSendSuper);
  return sendSuper(&mySuper, _cmd);
}

- (oneway void)debugRelease {
  // Insert debugging code here.

  struct objc_super mySuper = {.receiver = self,
                               .super_class = [self superclass]};
  using releaseSendSuper = void (*)(struct objc_super*, SEL);
  releaseSendSuper sendSuper =
      reinterpret_cast<releaseSendSuper>(objc_msgSendSuper);
  return sendSuper(&mySuper, _cmd);
}

#endif

- (BOOL)isKeyWindow {
  return _pretendIsKeyWindow;
}

- (BOOL)isOnActiveSpace {
  return _pretendIsOnActiveSpace;
}

- (void)makePretendKeyWindowAndSetFirstResponder:(NSResponder*)responder {
  EXPECT_TRUE([self makeFirstResponder:responder]);
  self.pretendIsKeyWindow = YES;
}

- (void)clearPretendKeyWindowAndFirstResponder {
  self.pretendIsKeyWindow = NO;
  EXPECT_TRUE([self makeFirstResponder:NSApp]);
}

- (void)setPretendIsOnActiveSpace:(BOOL)pretendIsOnActiveSpace {
  _pretendIsOnActiveSpace = pretendIsOnActiveSpace;
  [NSWorkspace.sharedWorkspace.notificationCenter
      postNotificationName:NSWorkspaceActiveSpaceDidChangeNotification
                    object:NSWorkspace.sharedWorkspace];
}

- (void)setPretendFullKeyboardAccessIsEnabled:(BOOL)enabled {
  EXPECT_TRUE([NSWindow
      instancesRespondToSelector:@selector(_allowsAnyValidResponder)]);
  _pretendFullKeyboardAccessIsEnabled = enabled;
  [self recalculateKeyViewLoop];
}

// Override of an undocumented AppKit method which controls call to check if
// full keyboard access is enabled. Its presence is verified in
// -setPretendFullKeyboardAccessIsEnabled:.
- (BOOL)_allowsAnyValidResponder {
  return _pretendFullKeyboardAccessIsEnabled;
}

- (NSArray<NSView*>*)validKeyViews {
  NSMutableArray<NSView*>* validKeyViews = [NSMutableArray array];
  NSView* contentView = self.contentView;
  if (contentView.canBecomeKeyView) {
    [validKeyViews addObject:contentView];
  }
  for (NSView* keyView = contentView.nextValidKeyView;
       keyView != nil && ![validKeyViews containsObject:keyView];
       keyView = keyView.nextValidKeyView) {
    [validKeyViews addObject:keyView];
  }
  return validKeyViews;
}

- (NSRect)constrainFrameRect:(NSRect)frameRect toScreen:(NSScreen*)screen {
  if (!_useDefaultConstraints) {
    return frameRect;
  }

  return [super constrainFrameRect:frameRect toScreen:screen];
}

@end

namespace ui {

CocoaTestHelper::CocoaTestHelper() {
  // If a test suite hasn't already initialized NSApp, register the mock one
  // now.
  if (!NSApp) {
    mock_cr_app::RegisterMockCrApp();
  }

  // Set the duration of AppKit-evaluated animations (such as frame changes)
  // to zero for testing purposes. That way they take effect immediately.
  NSAnimationContext.currentContext.duration = 0.0;

  // The above does not affect window-resize time, such as for an
  // attached sheet dropping in.  Set that duration for the current
  // process (this is not persisted).  Empirically, the value of 0.0
  // is ignored.
  NSDictionary* dict = @{@"NSWindowResizeTime" : @"0.01"};
  [NSUserDefaults.standardUserDefaults registerDefaults:dict];

  MarkCurrentWindowsAsInitial();
}

CocoaTestHelper::~CocoaTestHelper() {
  // Call close on the test_window to clean it up if one was opened.
  [test_window_ clearPretendKeyWindowAndFirstResponder];
  [test_window_ close];
  test_window_ = nil;

  // Recycle the pool to clean up any stuff that was put on the
  // autorelease pool due to window or window controller closures.
  pool_.Recycle();

  // Some controls (NSTextFields, NSComboboxes etc) use
  // performSelector:withDelay: to clean up drag handlers and other
  // things (Radar 5851458 "Closing a window with a NSTextView in it
  // should get rid of it immediately").  The event loop must be spun
  // to get everything cleaned up correctly.  It normally only takes
  // one to two spins through the event loop to see a change.

  // NOTE(shess): Under valgrind, -nextEventMatchingMask:* in one test
  // needed to run twice, once taking .2 seconds, the next time .6
  // seconds.  The loop exit condition attempts to be scalable.

  // Get the set of windows which weren't present when the test
  // started.
  WeakWindowVector windows_left = WindowsLeft();

  while (!windows_left.empty()) {
    // Cover delayed actions by spinning the loop at least once after
    // this timeout.
    const NSTimeInterval kCloseTimeoutSeconds =
        TestTimeouts::action_timeout().InSecondsF();

    // Cover chains of delayed actions by spinning the loop at least
    // this many times.
    const int kCloseSpins = 3;

    // Track the set of remaining windows so that everything can be
    // reset if progress is made.
    WeakWindowVector still_left = windows_left;

    NSDate* start_date = [NSDate date];
    bool one_more_time = true;
    int spins = 0;
    while (still_left.size() == windows_left.size() &&
           (spins < kCloseSpins || one_more_time)) {
      // Check the timeout before pumping events, so that we'll spin
      // the loop once after the timeout.
      one_more_time = start_date.timeIntervalSinceNow > -kCloseTimeoutSeconds;

      // Autorelease anything thrown up by the event loop.
      @autoreleasepool {
        ++spins;
        NSEvent* next_event = [NSApp nextEventMatchingMask:NSEventMaskAny
                                                 untilDate:nil
                                                    inMode:NSDefaultRunLoopMode
                                                   dequeue:YES];
        [NSApp sendEvent:next_event];
        [NSApp updateWindows];
      }

      // Refresh the outstanding windows.
      still_left = WindowsLeft();
    }

    // If no progress is being made, log a failure and continue.
    if (still_left.size() == windows_left.size()) {
      // NOTE(shess): Failing this expectation means that the test
      // opened windows which have not been fully released.  Either
      // there is a leak, or perhaps one of |kCloseTimeoutSeconds| or
      // |kCloseSpins| needs adjustment.
      EXPECT_EQ(0U, windows_left.size());
      for (NSWindow* __weak window : windows_left) {
        LOG(WARNING) << "Didn't close window "
                     << base::SysNSStringToUTF8(window.description);
      }
      break;
    }

    windows_left = still_left;
  }
}

void CocoaTestHelper::MarkCurrentWindowsAsInitial() {
  // Collect the list of windows that were open when the test started so
  // that we don't wait for them to close in TearDown. Has to be done
  // after BootstrapCocoa is called.
  initial_windows_ = ApplicationWindows();
}

CocoaTestHelperWindow* CocoaTestHelper::test_window() {
  if (!test_window_) {
    test_window_ = [[CocoaTestHelperWindow alloc] init];
    if (base::debug::BeingDebugged()) {
      [test_window_ orderFront:nil];
    } else {
      [test_window_ orderBack:nil];
    }
  }
  return test_window_;
}

// Returns a vector of currently open windows.
CocoaTestHelper::WeakWindowVector CocoaTestHelper::ApplicationWindows() {
  WeakWindowVector windows;

  // Must create a pool here because [NSApp windows] has created an array which
  // retains all the windows in it.
  @autoreleasepool {
    for (NSWindow* window in NSApp.windows) {
      windows.push_back(window);
    }
    return windows;
  }
}

CocoaTestHelper::WeakWindowVector CocoaTestHelper::WindowsLeft() {
  // Window pointers can go nil only when the run loop is going, so it's safe to
  // use sets within this function, just not outside it.
  using WeakWindowSet = std::set<NSWindow * __weak>;

  WeakWindowVector windows = ApplicationWindows();
  WeakWindowSet windows_set(windows.begin(), windows.end());

  // Ignore TextInputUIMacHelper.framework created TUINSWindow. We have no
  // control or documentation about these windows, ignoring them seems like the
  // best approach.
  std::erase_if(windows_set, [](NSWindow* __weak set_window) {
    return [set_window isKindOfClass:NSClassFromString(@"TUINSWindow")];
  });

  // Subtract away the initial windows. The current window set will not have any
  // nil values, as it was just obtained, so subtracting away the nil from any
  // initial windows that have been closed is safe.
  WeakWindowSet initial_windows_set(initial_windows_.begin(),
                                    initial_windows_.end());

  WeakWindowSet windows_left_set =
      base::STLSetDifference<WeakWindowSet>(windows_set, initial_windows_set);
  return std::vector(windows_left_set.begin(), windows_left_set.end());
}

CocoaTest::CocoaTest() : helper_(std::make_unique<CocoaTestHelper>()) {}
CocoaTest::~CocoaTest() {
  CHECK(!helper_);
}

void CocoaTest::TearDown() {
  helper_.reset();
  PlatformTest::TearDown();
}

void CocoaTest::MarkCurrentWindowsAsInitial() {
  helper_->MarkCurrentWindowsAsInitial();
}

}  // namespace ui