From a104941315376a3f753ba6b573a8226fb037435e Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 14 Jun 2026 13:58:46 -0400 Subject: [PATCH 1/5] Speed up UI tests --- Free Ruler.xcodeproj/project.pbxproj | 14 ++ Free Ruler/AppDelegate.swift | 59 ++----- Free Ruler/PreferencesController.swift | 2 +- Free Ruler/RulerCursorController.swift | 2 +- Free Ruler/UITestSupport+App.swift | 57 +++++++ Free Ruler/UITestSupport.swift | 141 +++++++++++++++++ FreeRulerUITests/FreeRulerUITests.swift | 196 +++++++----------------- 7 files changed, 282 insertions(+), 189 deletions(-) create mode 100644 Free Ruler/UITestSupport+App.swift create mode 100644 Free Ruler/UITestSupport.swift diff --git a/Free Ruler.xcodeproj/project.pbxproj b/Free Ruler.xcodeproj/project.pbxproj index da48a55..bab35bf 100644 --- a/Free Ruler.xcodeproj/project.pbxproj +++ b/Free Ruler.xcodeproj/project.pbxproj @@ -40,7 +40,12 @@ 50B1D3672D06100000B1D13A /* ResizeHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */; }; 50B1D3692D06100000B1D13A /* ResizeHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */; }; 50B1D36A2D06110000B1D13B /* UnitLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */; }; + 50B1D3712D06120000B1D140 /* UITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3702D06120000B1D140 /* UITestSupport.swift */; }; 50B1D36B2D06110000B1D13B /* UnitLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */; }; + 50B1D3722D06120000B1D140 /* UITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3702D06120000B1D140 /* UITestSupport.swift */; }; + 50B1D3732D06120000B1D141 /* UITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3702D06120000B1D140 /* UITestSupport.swift */; }; + 50B1D3752D06120000B1D141 /* UITestSupport+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3742D06120000B1D141 /* UITestSupport+App.swift */; }; + 50B1D3762D06120000B1D141 /* UITestSupport+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3742D06120000B1D141 /* UITestSupport+App.swift */; }; 50C6D891228BDBAD0091F19E /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50C6D890228BDBAD0091F19E /* Images.xcassets */; }; 50D7BEE7227D42FD0008B95E /* RulerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7BEE6227D42FD0008B95E /* RulerController.swift */; }; 50D7BEE9227D43270008B95E /* Ruler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7BEE8227D43270008B95E /* Ruler.swift */; }; @@ -121,6 +126,8 @@ 50B1D3642D06010100B1D139 /* AppStoreScreenshotPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreScreenshotPreview.swift; sourceTree = ""; }; 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeHandleView.swift; sourceTree = ""; }; 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitLabelView.swift; sourceTree = ""; }; + 50B1D3702D06120000B1D140 /* UITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestSupport.swift; sourceTree = ""; }; + 50B1D3742D06120000B1D141 /* UITestSupport+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITestSupport+App.swift"; sourceTree = ""; }; 50C6D890228BDBAD0091F19E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 50D7BEE6227D42FD0008B95E /* RulerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RulerController.swift; sourceTree = ""; }; 50D7BEE8227D43270008B95E /* Ruler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Ruler.swift; sourceTree = ""; }; @@ -245,6 +252,8 @@ 507FED5F2280E13200BD77DC /* Prefs.swift */, 50008B6022846FCD001E3EE4 /* Notifications.swift */, 6F4102882260712F00F06A10 /* AppDelegate.swift */, + 50B1D3702D06120000B1D140 /* UITestSupport.swift */, + 50B1D3742D06120000B1D141 /* UITestSupport+App.swift */, 50B1D3522D05D00100B1D135 /* RulerCursorController.swift */, 50B1D3502D05D00000B1D135 /* MouseTickTimerPolicy.swift */, 50B149002D04A00000149C0D /* HotkeyBezel.swift */, @@ -459,6 +468,7 @@ buildActionMask = 2147483647; files = ( 50A147012D03C00000147C0D /* FreeRulerUITests.swift in Sources */, + 50B1D3732D06120000B1D141 /* UITestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -481,6 +491,8 @@ 50D7BEE7227D42FD0008B95E /* RulerController.swift in Sources */, 5012CAAD226AB09000BD9565 /* VerticalRule.swift in Sources */, 6F4102892260712F00F06A10 /* AppDelegate.swift in Sources */, + 50B1D3712D06120000B1D140 /* UITestSupport.swift in Sources */, + 50B1D3752D06120000B1D141 /* UITestSupport+App.swift in Sources */, 50D7BEED227D5C810008B95E /* RuleView.swift in Sources */, 50B1D3672D06100000B1D13A /* ResizeHandleView.swift in Sources */, 50B1D36A2D06110000B1D13B /* UnitLabelView.swift in Sources */, @@ -507,6 +519,8 @@ 0C56237FE08F4155F0B63FFB /* RulerController.swift in Sources */, 5B52743D84FFD5EBF40B8558 /* VerticalRule.swift in Sources */, 23E2B80975F07733EB6CF5DB /* AppDelegate.swift in Sources */, + 50B1D3722D06120000B1D140 /* UITestSupport.swift in Sources */, + 50B1D3762D06120000B1D141 /* UITestSupport+App.swift in Sources */, 9A082BBC4A583513B0858C41 /* RuleView.swift in Sources */, 50B1D3692D06100000B1D13A /* ResizeHandleView.swift in Sources */, 50B1D36B2D06110000B1D13B /* UnitLabelView.swift in Sources */, diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index bd7c5cc..862d921 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -7,26 +7,6 @@ import Darwin import Sparkle #endif -let env = ProcessInfo.processInfo.environment -let UI_TESTS = env["FREE_RULER_UI_TESTS"] != nil -let UI_TEST_CURSOR_STATE_NAME: String? = { - guard UI_TESTS, - let name = env["FREE_RULER_UI_TEST_CURSOR_STATE_NAME"] else { return nil } - - let lastPathComponent = URL(fileURLWithPath: name).lastPathComponent - guard !lastPathComponent.isEmpty, lastPathComponent == name else { return nil } - - return name -}() - -func writeUITestCursorState(_ value: String) { - guard let name = UI_TEST_CURSOR_STATE_NAME else { return } - - let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(name) - try? value.write(to: url, atomically: true, encoding: .utf8) -} - private enum HotkeyBezelLocalizationKey: String { case rulersFloated = "HotkeyBezel.RulersFloated" case rulersUnfloated = "HotkeyBezel.RulersUnfloated" @@ -117,6 +97,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var preferencesController: PreferencesController? = nil private let hotkeyBezel = HotkeyBezel() + private var uiTestSupport: UITestSupport? #if SPARKLE private var updaterController: SPUStandardUpdaterController? #endif @@ -133,13 +114,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } #endif - if UI_TESTS { - resetStateForUITests() - writeUITestCursorState("none") - } + uiTestSupport = UITestSupport.installIfNeeded() + uiTestSupport?.resetApplicationState() + uiTestSupport?.writeCursorState("none") subscribeToPrefs() updateDisplay() + uiTestSupport?.writePreferencesState() #if SPARKLE configureUpdater() #endif @@ -222,15 +203,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefs.observe(\Prefs.unit, options: .new) { prefs, changed in self.updateUnitMenu() self.redrawRulers() + self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersMenuItem() + self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in self.updateGroupRulersMenuItem() + self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowMenuItem() + self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in self.redrawRulers() @@ -272,32 +257,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { rulerShadowMenuItem?.state = prefs.rulerShadow ? .on : .off } - private func resetStateForUITests() { - let defaults = UserDefaults.standard - [ - "groupRulers", - "floatRulers", - "rulerShadow", - "foregroundOpacity", - "backgroundOpacity", - "rulerColor", - "unit", - "zeroCorner", - "NSWindow Frame horizontal-ruler", - "NSWindow Frame vertical-ruler", - "NSWindow Frame preferencesWindow", - ].forEach(defaults.removeObject(forKey:)) - - prefs.groupRulers = true - prefs.floatRulers = true - prefs.rulerShadow = false - prefs.foregroundOpacity = 90 - prefs.backgroundOpacity = 50 - prefs.rulerColor = Prefs.defaultRulerFillColor - prefs.unit = .pixels - prefs.zeroCorner = Prefs.defaultZeroCorner - } - func createRulersIfNeeded() { guard rulers.isEmpty else { return } diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index be2db7e..feb9d3c 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -8,7 +8,7 @@ func configureOpaqueColorPicking() { let colorPanel = NSColorPanel.shared colorPanel.identifier = rulerColorPanelIdentifier colorPanel.setAccessibilityIdentifier(rulerColorPanelIdentifier.rawValue) - if UI_TESTS { + if UITestSupport.isEnabled { colorPanel.setAccessibilityValue(rulerColorPanelOpaqueAccessibilityValue) } setColorPickingIgnoresAlpha(true) diff --git a/Free Ruler/RulerCursorController.swift b/Free Ruler/RulerCursorController.swift index 6ff2b2e..30568ec 100644 --- a/Free Ruler/RulerCursorController.swift +++ b/Free Ruler/RulerCursorController.swift @@ -102,6 +102,6 @@ final class RulerCursorController { private extension RulerCursorController.CursorStyle { func writeUITestStateIfNeeded() { - writeUITestCursorState(uiTestStateValue) + UITestSupport.current?.writeCursorState(uiTestStateValue) } } diff --git a/Free Ruler/UITestSupport+App.swift b/Free Ruler/UITestSupport+App.swift new file mode 100644 index 0000000..09bb087 --- /dev/null +++ b/Free Ruler/UITestSupport+App.swift @@ -0,0 +1,57 @@ +import Foundation + +extension UITestSupport { + func resetApplicationState() { + let defaults = UserDefaults.standard + [ + "groupRulers", + "floatRulers", + "rulerShadow", + "foregroundOpacity", + "backgroundOpacity", + "rulerColor", + "unit", + "zeroCorner", + "NSWindow Frame horizontal-ruler", + "NSWindow Frame vertical-ruler", + "NSWindow Frame preferencesWindow", + ].forEach(defaults.removeObject(forKey:)) + + prefs.groupRulers = true + prefs.floatRulers = true + prefs.rulerShadow = false + prefs.foregroundOpacity = 90 + prefs.backgroundOpacity = 50 + prefs.rulerColor = Prefs.defaultRulerFillColor + prefs.unit = .pixels + prefs.zeroCorner = Prefs.defaultZeroCorner + } + + func writePreferencesState() { + let state = [ + "floatRulers": boolStateValue(prefs.floatRulers), + "groupRulers": boolStateValue(prefs.groupRulers), + "rulerShadow": boolStateValue(prefs.rulerShadow), + "unit": unitStateValue(prefs.unit), + ] + guard let data = try? JSONSerialization.data(withJSONObject: state, options: [.sortedKeys]), + let value = String(data: data, encoding: .utf8) else { return } + + writeState(value, to: preferencesStateURL) + } + + private func boolStateValue(_ value: Bool) -> String { + return value ? "true" : "false" + } + + private func unitStateValue(_ unit: Unit) -> String { + switch unit { + case .pixels: + return "px" + case .millimeters: + return "mm" + case .inches: + return "in" + } + } +} diff --git a/Free Ruler/UITestSupport.swift b/Free Ruler/UITestSupport.swift new file mode 100644 index 0000000..3230d09 --- /dev/null +++ b/Free Ruler/UITestSupport.swift @@ -0,0 +1,141 @@ +import Darwin +import Foundation + +final class UITestSupport { + private static let uiTestsEnvironmentKey = "FREE_RULER_UI_TESTS" + private static let cursorStateNameEnvironmentKey = "FREE_RULER_UI_TEST_CURSOR_STATE_NAME" + private static let preferencesStateNameEnvironmentKey = "FREE_RULER_UI_TEST_PREFERENCES_STATE_NAME" + + private static var installedSupport: UITestSupport? + + static var current: UITestSupport? { + return installedSupport + } + + static var isEnabled: Bool { + return isEnabled(in: ProcessInfo.processInfo.environment) + } + + let stateNamePrefix: String + let stateDirectoryURL: URL + let cursorStateName: String + let preferencesStateName: String + + var cursorStateURL: URL { + return stateDirectoryURL.appendingPathComponent(cursorStateName) + } + + var preferencesStateURL: URL { + return stateDirectoryURL.appendingPathComponent(preferencesStateName) + } + + var launchEnvironment: [String: String] { + return [ + Self.uiTestsEnvironmentKey: "1", + Self.cursorStateNameEnvironmentKey: cursorStateName, + Self.preferencesStateNameEnvironmentKey: preferencesStateName, + ] + } + + private init( + stateNamePrefix: String, + cursorStateName: String? = nil, + preferencesStateName: String? = nil, + stateDirectoryURL: URL + ) { + self.stateNamePrefix = stateNamePrefix + self.stateDirectoryURL = stateDirectoryURL + self.cursorStateName = cursorStateName ?? "\(stateNamePrefix).cursor" + self.preferencesStateName = preferencesStateName ?? "\(stateNamePrefix).preferences" + } + + @discardableResult + static func prepareForLaunch() -> UITestSupport { + let support = UITestSupport( + stateNamePrefix: makeStateNamePrefix(), + stateDirectoryURL: appContainerTemporaryDirectoryURL() + ) + installedSupport = support + return support + } + + @discardableResult + static func installIfNeeded( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> UITestSupport? { + guard isEnabled(in: environment) else { + installedSupport = nil + return nil + } + + let stateNamePrefix = makeStateNamePrefix() + let support = UITestSupport( + stateNamePrefix: stateNamePrefix, + cursorStateName: stateName( + from: cursorStateNameEnvironmentKey, + in: environment + ), + preferencesStateName: stateName( + from: preferencesStateNameEnvironmentKey, + in: environment + ), + stateDirectoryURL: processTemporaryDirectoryURL() + ) + installedSupport = support + return support + } + + private static func isEnabled(in environment: [String: String]) -> Bool { + return environment[uiTestsEnvironmentKey] != nil + } + + func resetStateFiles() { + try? FileManager.default.createDirectory( + at: stateDirectoryURL, + withIntermediateDirectories: true + ) + removeStateFiles() + } + + func removeStateFiles() { + try? FileManager.default.removeItem(at: cursorStateURL) + try? FileManager.default.removeItem(at: preferencesStateURL) + } + + func writeCursorState(_ value: String) { + writeState(value, to: cursorStateURL) + } + + func writeState(_ value: String, to url: URL) { + try? value.write(to: url, atomically: true, encoding: .utf8) + } + + private static func stateName(from environmentKey: String, in environment: [String: String]) -> String? { + guard let name = environment[environmentKey] else { return nil } + + let lastPathComponent = URL(fileURLWithPath: name).lastPathComponent + guard !lastPathComponent.isEmpty, lastPathComponent == name else { return nil } + return name + } + + private static func makeStateNamePrefix() -> String { + return "FreeRulerUITests-\(UUID().uuidString)" + } + + private static func processTemporaryDirectoryURL() -> URL { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + } + + private static func appContainerTemporaryDirectoryURL() -> URL { + return URL(fileURLWithPath: currentUserHomeDirectory()) + .appendingPathComponent("Library/Containers/com.pascal.freeruler/Data/tmp", isDirectory: true) + } + + private static func currentUserHomeDirectory() -> String { + guard let passwd = getpwuid(getuid()) else { + return NSHomeDirectory() + } + + return String(cString: passwd.pointee.pw_dir) + } +} diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 7420e8f..9148346 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -1,30 +1,20 @@ import XCTest -import Darwin final class FreeRulerUITests: XCTestCase { private let opaqueColorPanelValue = "ruler-color-panel-alpha-hidden" private var app: XCUIApplication! - private var cursorStateURL: URL! + private var uiTestSupport: UITestSupport! override func setUpWithError() throws { continueAfterFailure = false - let cursorStateName = "FreeRulerUITests-\(UUID().uuidString).cursor" - let homeDirectory = currentUserHomeDirectory() - cursorStateURL = URL(fileURLWithPath: homeDirectory) - .appendingPathComponent("Library/Containers/com.pascal.freeruler/Data/tmp", isDirectory: true) - .appendingPathComponent(cursorStateName) - try? FileManager.default.createDirectory( - at: cursorStateURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try? FileManager.default.removeItem(at: cursorStateURL) + uiTestSupport = UITestSupport.prepareForLaunch() + uiTestSupport.resetStateFiles() app = XCUIApplication() - app.launchEnvironment["FREE_RULER_UI_TESTS"] = "1" - app.launchEnvironment["FREE_RULER_UI_TEST_CURSOR_STATE_NAME"] = cursorStateName + app.launchEnvironment.merge(uiTestSupport.launchEnvironment) { _, newValue in newValue } app.launch() app.activate() } @@ -32,8 +22,8 @@ final class FreeRulerUITests: XCTestCase { override func tearDownWithError() throws { app.terminate() app = nil - try? FileManager.default.removeItem(at: cursorStateURL) - cursorStateURL = nil + uiTestSupport.removeStateFiles() + uiTestSupport = nil } func testRulerVisibilityKeyboardCommands() { @@ -60,39 +50,31 @@ final class FreeRulerUITests: XCTestCase { func testGroupedRulerToggleUngroupsAndHidesRequestedRuler() { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - - horizontalRuler.click() - setGroupRulers(true) - XCTAssertTrue(groupRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) horizontalRuler.click() app.typeKey("v", modifierFlags: []) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertFalse(groupRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } func testGroupRulersKeyboardCommandUngroupsOnFirstAttempt() { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - - horizontalRuler.click() - setGroupRulers(true) - XCTAssertTrue(groupRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) horizontalRuler.click() app.typeKey("g", modifierFlags: []) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) - XCTAssertFalse(groupRulersEnabledInPreferences()) - - setGroupRulers(true) - XCTAssertTrue(groupRulersEnabledInPreferences()) + app.typeKey("g", modifierFlags: []) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) verticalRuler.click() app.typeKey("g", modifierFlags: []) - - XCTAssertFalse(groupRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } func testPreferencesCloseWithCommandW() { @@ -184,30 +166,22 @@ final class FreeRulerUITests: XCTestCase { func testFloatShadowAndUnitKeyboardCommands() { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - - horizontalRuler.click() - XCTAssertTrue(floatRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("floatRulers", equals: true)) + XCTAssertTrue(waitForPreference("rulerShadow", equals: false)) horizontalRuler.click() app.typeKey("f", modifierFlags: []) - XCTAssertFalse(floatRulersEnabledInPreferences()) + XCTAssertTrue(waitForPreference("floatRulers", equals: false)) - horizontalRuler.click() app.typeKey("f", modifierFlags: []) - XCTAssertTrue(floatRulersEnabledInPreferences()) - - horizontalRuler.click() - XCTAssertFalse(rulerShadowEnabledInPreferences()) + XCTAssertTrue(waitForPreference("floatRulers", equals: true)) - horizontalRuler.click() app.typeKey("s", modifierFlags: []) - XCTAssertTrue(rulerShadowEnabledInPreferences()) + XCTAssertTrue(waitForPreference("rulerShadow", equals: true)) - horizontalRuler.click() app.typeKey("s", modifierFlags: []) - XCTAssertFalse(rulerShadowEnabledInPreferences()) + XCTAssertTrue(waitForPreference("rulerShadow", equals: false)) - horizontalRuler.click() XCTAssertEqual(horizontalRulerView.value as? String, "px") app.typeKey("u", modifierFlags: []) @@ -293,10 +267,7 @@ final class FreeRulerUITests: XCTestCase { func testGroupedRulerCursorsForKeyAndChildWindows() { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - - setGroupRulers(true) - XCTAssertTrue(groupRulersEnabledInPreferences()) - closePreferences() + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) horizontalRuler.click() assertCursorSequence(on: horizontalRulerView, label: "grouped key horizontal ruler") @@ -327,18 +298,6 @@ final class FreeRulerUITests: XCTestCase { app.windows["Free Ruler Preferences"] } - private var floatRulersCheckbox: XCUIElement { - app.checkBoxes["float-rulers-checkbox"] - } - - private var groupRulersCheckbox: XCUIElement { - app.checkBoxes["group-rulers-checkbox"] - } - - private var rulerShadowCheckbox: XCUIElement { - app.checkBoxes["ruler-shadow-checkbox"] - } - private var rulerColorWell: XCUIElement { app.colorWells["ruler-color-well"] } @@ -362,14 +321,6 @@ final class FreeRulerUITests: XCTestCase { } } - private func closePreferences() { - if preferencesWindow.exists { - preferencesWindow.click() - app.typeKey("w", modifierFlags: .command) - XCTAssertTrue(preferencesWindow.waitForNonExistence(timeout: 2)) - } - } - private func openRulerColorPanel() { openPreferences() @@ -383,37 +334,13 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(colorPanel.waitForExistence(timeout: 3)) } - private func setGroupRulers(_ enabled: Bool) { - openPreferences() - - if groupRulersCheckbox.isChecked != enabled { - groupRulersCheckbox.click() - } - } - - private func groupRulersEnabledInPreferences() -> Bool { - openPreferences() - return groupRulersCheckbox.isChecked - } - - private func floatRulersEnabledInPreferences() -> Bool { - openPreferences() - return floatRulersCheckbox.isChecked - } - - private func rulerShadowEnabledInPreferences() -> Bool { - openPreferences() - return rulerShadowCheckbox.isChecked - } - private func isolateHorizontalRulerByUngroupingWithVerticalToggle() { horizontalRuler.click() app.typeKey("v", modifierFlags: []) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertFalse(groupRulersEnabledInPreferences()) - closePreferences() + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } private func assertCursorSequence(on ruler: XCUIElement, label: String) { @@ -425,15 +352,6 @@ final class FreeRulerUITests: XCTestCase { hover(over: pointOutside(ruler)) assertCursor("crosshair", after: "mouseout \(label)") - - hover(over: ruler) - assertCursor("open-hand", after: "mouseover \(label) again") - - pressAndRelease(in: ruler, assertingCursorDuringPress: "closed-hand") - assertCursor("open-hand", after: "mousedown and mouseup inside \(label) again") - - hover(over: pointOutside(ruler)) - assertCursor("crosshair", after: "mouseout \(label) again") } private func waitForHotkeyBezel(_ expectedLabel: String, timeout: TimeInterval = 2) -> Bool { @@ -488,10 +406,9 @@ final class FreeRulerUITests: XCTestCase { private func expectationForCursor(_ expectedCursor: String, after action: String) -> XCTestExpectation { let expectation = expectation(description: "Expected cursor \(expectedCursor) after \(action)") + let cursorStateURL = uiTestSupport.cursorStateURL - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.15) { [cursorStateURL] in - guard let cursorStateURL = cursorStateURL else { return } - + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.15) { let deadline = Date().addingTimeInterval(1.5) while Date() < deadline { @@ -538,15 +455,46 @@ final class FreeRulerUITests: XCTestCase { } private func readCursorState() -> String? { - return try? String(contentsOf: cursorStateURL, encoding: .utf8) + return try? String(contentsOf: uiTestSupport.cursorStateURL, encoding: .utf8) + } + + private func waitForPreference( + _ key: String, + equals expectedValue: Bool, + timeout: TimeInterval = 2 + ) -> Bool { + return waitForPreference( + key, + equals: expectedValue ? "true" : "false", + timeout: timeout + ) + } + + private func waitForPreference( + _ key: String, + equals expectedValue: String, + timeout: TimeInterval = 2 + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if readPreferenceState()[key] == expectedValue { + return true + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + return readPreferenceState()[key] == expectedValue } - private func currentUserHomeDirectory() -> String { - guard let passwd = getpwuid(getuid()) else { - return NSHomeDirectory() + private func readPreferenceState() -> [String: String] { + guard let data = try? Data(contentsOf: uiTestSupport.preferencesStateURL), + let state = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] } - return String(cString: passwd.pointee.pw_dir) + return state } private func visibleSliderCount(in element: XCUIElement) -> Int { @@ -561,20 +509,6 @@ private extension XCUIElement { return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed } - func waitForNoVisibleFrame(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - if !hasVisibleFrame { - return true - } - - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - - return !hasVisibleFrame - } - func waitForVisibleFrame(timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -606,16 +540,4 @@ private extension XCUIElement { return !frame.equalTo(originalFrame) } - - var isChecked: Bool { - if let value = value as? String { - return value == "1" - } - - if let value = value as? NSNumber { - return value.boolValue - } - - return false - } } From 978eff788410feb27bbe2b00064355707f897b64 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 14 Jun 2026 14:11:48 -0400 Subject: [PATCH 2/5] Combine cursor UI test scenarios --- FreeRulerUITests/FreeRulerUITests.swift | 59 +++++++++++++++---------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 9148346..2e92bf2 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -241,41 +241,44 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(verticalRuler.waitForFrameChange(from: originalVerticalFrame, timeout: 2)) } - func testHorizontalRulerCursorForMouseoverMousedownAndMouseoutActions() { + func testRulerCursorsForGroupedAndUngroupedScenarios() { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - isolateHorizontalRulerByUngroupingWithVerticalToggle() + XCTContext.runActivity(named: "ungrouped horizontal ruler cursor") { _ in + resetRulerCursorScenario() + isolateHorizontalRulerByUngroupingWithVerticalToggle() - assertCursorSequence(on: horizontalRuler, label: "ungrouped horizontal ruler") - } + assertCursorSequence(on: horizontalRuler, label: "ungrouped horizontal ruler") + } - func testVerticalRulerCursorForMouseoverMousedownAndMouseoutActions() { - XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) + XCTContext.runActivity(named: "ungrouped vertical ruler cursor") { _ in + resetRulerCursorScenario() + isolateHorizontalRulerByUngroupingWithVerticalToggle() - isolateHorizontalRulerByUngroupingWithVerticalToggle() + app.typeKey("h", modifierFlags: []) + XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) + app.typeKey("v", modifierFlags: []) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) - app.typeKey("h", modifierFlags: []) - XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) - app.typeKey("v", modifierFlags: []) - XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + assertCursorSequence(on: verticalRuler, label: "ungrouped vertical ruler") + } - assertCursorSequence(on: verticalRuler, label: "ungrouped vertical ruler") - } + XCTContext.runActivity(named: "grouped cursor with horizontal key ruler") { _ in + resetRulerCursorScenario() - func testGroupedRulerCursorsForKeyAndChildWindows() { - XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + horizontalRuler.click() + assertCursorSequence(on: horizontalRulerView, label: "grouped key horizontal ruler") + assertCursorSequence(on: verticalRulerView, label: "grouped child vertical ruler") + } - horizontalRuler.click() - assertCursorSequence(on: horizontalRulerView, label: "grouped key horizontal ruler") - assertCursorSequence(on: verticalRulerView, label: "grouped child vertical ruler") + XCTContext.runActivity(named: "grouped cursor with vertical key ruler") { _ in + resetRulerCursorScenario() - verticalRuler.click() - assertCursorSequence(on: verticalRulerView, label: "grouped key vertical ruler") - assertCursorSequence(on: horizontalRulerView, label: "grouped child horizontal ruler") + verticalRuler.click() + assertCursorSequence(on: verticalRulerView, label: "grouped key vertical ruler") + assertCursorSequence(on: horizontalRulerView, label: "grouped child horizontal ruler") + } } private var horizontalRuler: XCUIElement { @@ -343,6 +346,14 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } + private func resetRulerCursorScenario() { + app.typeKey("r", modifierFlags: .command) + + XCTAssertTrue(horizontalRuler.waitForVisibleFrame(timeout: 1)) + XCTAssertTrue(verticalRuler.waitForVisibleFrame(timeout: 1)) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + } + private func assertCursorSequence(on ruler: XCUIElement, label: String) { hover(over: ruler) assertCursor("open-hand", after: "mouseover \(label)") From 58fc9b6c28821eca3c4eecc6af920eb1ba4b6103 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 14 Jun 2026 14:12:44 -0400 Subject: [PATCH 3/5] Update AGENTS.md --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5c619a9..aa0d9e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,18 @@ zips or other generated artifacts. AppKit patterns, explicit `NS*` types, and straightforward helpers rather than heavy abstraction. +## GitHub CLI In Codex + +`gh` is authenticated for the `pascalpp` account through the macOS keyring. +Sandboxed agent commands may not be able to read that keyring entry and can +report the token in `~/.config/gh/hosts.yml` as invalid even when +`gh auth status` works in the user's terminal. + +Before concluding that GitHub CLI auth is missing, retry authenticated `gh` +commands outside the sandbox with approval/escalation. Do not run `gh auth +login`, `gh auth logout`, or edit `~/.config/gh/hosts.yml` unless the escalated +`gh auth status` check also fails or the user explicitly asks for reauth. + ## PR Review Comment Resolution When asked to address PR review comments, inspect the pull request associated From 5fb1f79bb93b2cfa9be43857195cdf4d73ef8b07 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 14 Jun 2026 14:21:17 -0400 Subject: [PATCH 4/5] Shorten cursor UI press duration --- FreeRulerUITests/FreeRulerUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 2e92bf2..975baf6 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -396,7 +396,7 @@ final class FreeRulerUITests: XCTestCase { private func pressAndRelease(in element: XCUIElement, assertingCursorDuringPress expectedCursor: String) { let expectation = expectationForCursor(expectedCursor, after: "mousedown inside \(element.identifier)") let coordinate = interactionPoint(in: element) - coordinate.press(forDuration: 0.4) + coordinate.press(forDuration: 0.2) wait(for: [expectation], timeout: 1) RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } From 9d273173eeb02031b3b03eeb98c615f4edf5c968 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 14 Jun 2026 14:27:41 -0400 Subject: [PATCH 5/5] Add yarn test aliases --- AGENTS.md | 8 ++++++-- package.json | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa0d9e4..4ce80c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,14 +83,18 @@ Use Xcode's command-line tools from the repository root: ```sh xcodebuild -project "Free Ruler.xcodeproj" -scheme "Free Ruler" build xcodebuild -project "Free Ruler.xcodeproj" -scheme "Free Ruler" test -only-testing:FreeRulerTests +yarn test +yarn test:unit +yarn test:ui ``` For focused test work, prefer the smallest relevant Xcode test invocation first, then run the core/unit tests if the change affects shared behavior. Do not run the UI tests unless the user explicitly asks for them. -The `package.json` scripts are for versioning and release automation, not the -normal test suite. `npm test` is intentionally not wired to the Xcode tests. +The `package.json` test scripts are aliases for the Xcode test commands: +`yarn test:unit` runs `FreeRulerTests`; `yarn test:ui` runs +`FreeRulerUITests`; `yarn test` runs both. ## App Behavior Notes diff --git a/package.json b/package.json index 1ab1e44..b2d47c8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "A free ruler for macOS", "main": "none", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "yarn test:unit && yarn test:ui", + "test:unit": "xcodebuild -project 'Free Ruler.xcodeproj' -scheme 'Free Ruler' test -only-testing:FreeRulerTests", + "test:ui": "xcodebuild -project 'Free Ruler.xcodeproj' -scheme 'Free Ruler' test -only-testing:FreeRulerUITests", "get:version": "node -p \"require('./package.json').version\"", "get:commits": "git log --pretty=oneline | wc -l", "generate:icons": "scripts/generate-app-icon.sh",