chromium/chrome/browser/chrome_browser_application_mac_browsertest.mm

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

#import "chrome/browser/chrome_browser_application_mac.h"

#import "base/apple/foundation_util.h"
#import "base/apple/scoped_objc_class_swizzler.h"
#import "base/mac/mac_util.h"
#include "base/task/single_thread_task_runner.h"
#include "base/time/time.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "content/public/browser/browser_accessibility_state.h"
#include "content/public/common/content_features.h"
#include "content/public/test/browser_test.h"

namespace {
BOOL g_voice_over_enabled = NO;
}

@interface NSWorkspace (Extras)
- (BOOL)voiceOverEnabled;
- (void)setVoiceOverEnabled:(BOOL)flag;
@end

@implementation NSWorkspace (Extras)

- (BOOL)voiceOverEnabled {
  return g_voice_over_enabled;
}

// It seems NSWorkspace notifies of changes to voiceOverEnabled via KVO,
// but doesn't implement this method. We add it so we can test our KVO
// monitoring code.
- (void)setVoiceOverEnabled:(BOOL)flag {
  g_voice_over_enabled = flag;
}

@end

@interface NSApplication (ChromeBrowserApplicationMacBrowserTestSwizzle)
@end

@implementation NSApplication (ChromeBrowserApplicationMacBrowserTestSwizzle)

- (void)testObserveValueForKeyPath:(NSString*)keyPath
                          ofObject:(id)object
                            change:
                                (NSDictionary<NSKeyValueChangeKey, id>*)change
                           context:(void*)context {
  if (context) {
    *static_cast<bool*>(context) = true;
  }
}

@end

class ChromeBrowserAppMacBrowserTest : public InProcessBrowserTest {
 public:
  ChromeBrowserAppMacBrowserTest() {
    scoped_feature_list_.InitAndEnableFeature(
        features::kSonomaAccessibilityActivationRefinements);

    br_cr_app_ = base::apple::ObjCCast<BrowserCrApplication>(NSApp);
  }

  void SetUpCommandLine(base::CommandLine* command_line) override {
    SetVoiceOverEnabled(VoiceOverEnabledAtStartUp());
  }

  // Whether or not we simulate VoiceOver active before the test runs.
  virtual BOOL VoiceOverEnabledAtStartUp() { return NO; }

  BOOL VoiceOverEnabled() { return [br_cr_app_ voiceOverStateForTesting]; }

  // Simulates the user activating or deactivating VoiceOver.
  void SetVoiceOverEnabled(BOOL enabled) {
    NSString* kVoiceOverKVOKeyPath = @"voiceOverEnabled";
    [[NSWorkspace sharedWorkspace] setValue:[NSNumber numberWithBool:enabled]
                                     forKey:kVoiceOverKVOKeyPath];
  }

  void WaitThreeSeconds() {
    base::RunLoop run_loop;
    base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask(
        FROM_HERE,
        base::BindOnce(&base::RunLoop::Quit, base::Unretained(&run_loop)),
        base::Seconds(3));
    run_loop.Run();
  }

  bool BrowserIsInAccessibilityMode(ui::AXMode mode) {
    content::BrowserAccessibilityState* accessibility_state =
        content::BrowserAccessibilityState::GetInstance();
    return accessibility_state->GetAccessibilityMode() == mode;
  }

  bool BrowserIsInCompleteAccessibilityMode() {
    return BrowserIsInAccessibilityMode(ui::kAXModeComplete);
  }

  bool BrowserIsInBasicAccessibilityMode() {
    return BrowserIsInAccessibilityMode(ui::kAXModeBasic);
  }

  bool BrowserIsInNativeAPIAccessibilityMode() {
    return BrowserIsInAccessibilityMode(ui::AXMode::kNativeAPIs);
  }

  bool BrowserAccessibilityDisabled() {
    return BrowserIsInAccessibilityMode(ui::AXMode());
  }

  void RequestAppAccessibilityRole() { [br_cr_app_ accessibilityRole]; }

  void EnableEnhancedUserInterface(BOOL enable) {
    // We need to call -accessibilitySetValue:forAttribute: on br_cr_app_, but
    // the compiler complains it's deprecated API. It's right, but it's the API
    // BrowserCrApplication is currently using. Silence the error.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
    [br_cr_app_ accessibilitySetValue:[NSNumber numberWithBool:enable]
                         forAttribute:@"AXEnhancedUserInterface"];
#pragma clang diagnostic pop
  }

 private:
  BrowserCrApplication* br_cr_app_;
  base::test::ScopedFeatureList scoped_feature_list_;
};

// Ensures that overrides to the application's
// observeValueForKeyPath:ofObject:change:context: method call super on
// unrecognized key paths.
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       KVOObservationCallsSuper) {
  base::apple::ScopedObjCClassSwizzler swizzler(
      [NSApplication class],
      @selector(observeValueForKeyPath:ofObject:change:context:),
      @selector(testObserveValueForKeyPath:ofObject:change:context:));

  bool super_was_called = false;

  [NSApp observeValueForKeyPath:@"testKeyPath"
                       ofObject:nil
                         change:nil
                        context:&super_was_called];

  EXPECT_TRUE(super_was_called);
}

// Tests how BrowserCrApplication responds to VoiceOver activations.
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       RespondToVoiceOverStateChanges) {
  // We could perform this check in SetUp(), but in theory, this browser
  // test file will contain more that just these Sonoma tests.
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  EXPECT_FALSE(VoiceOverEnabled());
  EXPECT_TRUE(BrowserAccessibilityDisabled());

  SetVoiceOverEnabled(YES);
  EXPECT_TRUE(VoiceOverEnabled());
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());

  SetVoiceOverEnabled(NO);
  EXPECT_FALSE(VoiceOverEnabled());

  // Turning VoiceOver off disables accessibility support, but not immediately.
  // Chrome waits a couple seconds in case there's a fast-follow call to enable
  // it. Wait a bit for the change to take before proceeding.
  WaitThreeSeconds();

  EXPECT_TRUE(BrowserAccessibilityDisabled());
}

// Tests how BrowserCrApplication responds to AXEnhancedUserInterface requests
// from Assistive Technology (AT).
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       RespondToAXEnhancedUserInterfaceRequests) {
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  EXPECT_TRUE(BrowserAccessibilityDisabled());

  // Requesting AX enhanced UI should have no immediate effect.
  EnableEnhancedUserInterface(YES);
  EXPECT_TRUE(BrowserAccessibilityDisabled());

  // If we suddenly turn off support and wait a bit, there should be no change
  // in accessibility support. We're ensuring that sudden on/off changes are
  // ignored.
  EnableEnhancedUserInterface(NO);
  WaitThreeSeconds();
  EXPECT_TRUE(BrowserAccessibilityDisabled());

  // If we turn it on and wait, the code should assume it's not a spurious
  // request.
  EnableEnhancedUserInterface(YES);
  WaitThreeSeconds();
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());

  // Turn it off (and wait a bit).
  EnableEnhancedUserInterface(NO);
  WaitThreeSeconds();
  EXPECT_TRUE(BrowserAccessibilityDisabled());
}

// Tests how BrowserCrApplication responds to mismatched
// AXEnhancedUserInterface requests.
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       HandleMismatchedAXEnhancedUserInterfaceRequests) {
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  EXPECT_TRUE(BrowserAccessibilityDisabled());

  // The code uses a counter to track requests. Ensure that it can't go
  // negative.
  EnableEnhancedUserInterface(YES);
  EnableEnhancedUserInterface(NO);
  EnableEnhancedUserInterface(NO);
  EnableEnhancedUserInterface(NO);

  WaitThreeSeconds();

  EnableEnhancedUserInterface(YES);
  WaitThreeSeconds();
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());
}

// Tests that BrowserCrApplication ignores requests from ATs to disable AX
// support if VoiceOver is active.
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       IgnoreAXEnhancedUserInterfaceDisableRequests) {
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  EXPECT_TRUE(BrowserAccessibilityDisabled());

  // Simulate an AT requesting AX enhanced UI.
  EnableEnhancedUserInterface(YES);

  WaitThreeSeconds();

  // The user activates VoiceOver.
  SetVoiceOverEnabled(YES);
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());

  // When the AT is done, make sure it can't disable AX support (VoiceOver is
  // using it).
  EnableEnhancedUserInterface(NO);
  WaitThreeSeconds();
  EXPECT_FALSE(BrowserAccessibilityDisabled());
}

// Tests that accessibility role requests to the application enable native
// accessibility support.
IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserTest,
                       RespondToAccessibilityRoleRequests) {
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  EXPECT_TRUE(BrowserAccessibilityDisabled());

  RequestAppAccessibilityRole();
  EXPECT_TRUE(BrowserIsInNativeAPIAccessibilityMode());

  // The user activates VoiceOver.
  SetVoiceOverEnabled(YES);

  // Requests for AccessibilityRole when VoiceOver is active should not
  // downgrade the AX level.
  RequestAppAccessibilityRole();
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());

  SetVoiceOverEnabled(NO);
  WaitThreeSeconds();
  EXPECT_TRUE(BrowserAccessibilityDisabled());

  EnableEnhancedUserInterface(YES);
  WaitThreeSeconds();

  // Requests for AccessibilityRole when the AX mode is complete should not
  // downgrade the AX level.
  RequestAppAccessibilityRole();
  EXPECT_TRUE(BrowserIsInCompleteAccessibilityMode());
}

// A test class where VoiceOver is "enabled" when its tests start.
class ChromeBrowserAppMacBrowserMacVoiceOverEnabledTest
    : public ChromeBrowserAppMacBrowserTest {
 public:
  BOOL VoiceOverEnabledAtStartUp() override { return YES; }
};

IN_PROC_BROWSER_TEST_F(ChromeBrowserAppMacBrowserMacVoiceOverEnabledTest,
                       DetectVoiceOverStateOnStartUp) {
  if (base::mac::MacOSVersion() < 14'00'00) {
    GTEST_SKIP();
  }

  content::BrowserAccessibilityState* accessibility_state =
      content::BrowserAccessibilityState::GetInstance();

  // Enable VoiceOver.
  EXPECT_TRUE(VoiceOverEnabled());
  EXPECT_EQ(accessibility_state->GetAccessibilityMode(), ui::kAXModeComplete);
}