Revert UI test foreground activation changes back to 56a4d258
Revertscbb21872,54ec524a,10fd323b,75375ab7,82a16aa7— all attempts to fix display resolution UI test foreground activation on CI that introduced regressions. Restores the state from the last fully green CI run (56a4d258). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
82a16aa746
commit
fc858fcfa4
3 changed files with 35 additions and 187 deletions
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
|
@ -495,24 +495,12 @@ jobs:
|
|||
{"helperBinaryPath":"$HELPER_PATH"}
|
||||
EOF
|
||||
|
||||
# Retry once — foreground activation on headless CI runners is flaky
|
||||
for attempt in 1 2; do
|
||||
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" \
|
||||
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
|
||||
test; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "Display resolution UI regression failed after 2 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt failed, retrying..."
|
||||
pkill -x "cmux DEV" 2>/dev/null || true
|
||||
sleep 3
|
||||
done
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" \
|
||||
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
|
||||
test
|
||||
|
||||
- name: Run browser find focus UI regression
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -1929,10 +1929,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
Self.cachedIsRunningUnderXCTest
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private var uiTestForegroundActivationInFlight = false
|
||||
#endif
|
||||
|
||||
private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool {
|
||||
if env["XCTestConfigurationFilePath"] != nil { return true }
|
||||
if env["XCTestBundlePath"] != nil { return true }
|
||||
|
|
@ -2369,7 +2365,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM.
|
||||
// If there are no windows shortly after launch, force-create one so XCUITest can proceed.
|
||||
if isRunningUnderXCTest {
|
||||
scheduleUITestForegroundActivationIfNeeded(reason: "didFinishLaunching")
|
||||
if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] {
|
||||
UserDefaults.standard.set(
|
||||
BrowserImportHintSettings.variant(for: rawVariant).rawValue,
|
||||
|
|
@ -2394,7 +2389,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
self.openNewMainWindow(nil)
|
||||
}
|
||||
self.moveUITestWindowToTargetDisplayIfNeeded()
|
||||
self.scheduleUITestForegroundActivationIfNeeded(reason: "afterForceWindow")
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow")
|
||||
}
|
||||
if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" {
|
||||
|
|
@ -2420,12 +2415,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#endif
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
#if DEBUG
|
||||
scheduleUITestForegroundActivationIfNeeded(reason: "willFinishLaunching")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func writeUITestDiagnosticsIfNeeded(stage: String) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
|
|
@ -2444,8 +2433,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
payload["pid"] = String(ProcessInfo.processInfo.processIdentifier)
|
||||
payload["bundleId"] = Bundle.main.bundleIdentifier ?? ""
|
||||
payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0"
|
||||
payload["appIsActive"] = NSApp.isActive ? "1" : "0"
|
||||
payload["activationPolicy"] = debugActivationPolicyDescription(NSApp.activationPolicy())
|
||||
payload["windowsCount"] = String(windows.count)
|
||||
payload["windowIdentifiers"] = ids
|
||||
payload["windowVisibleFlags"] = vis
|
||||
|
|
@ -2577,66 +2564,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
private func scheduleUITestForegroundActivationIfNeeded(reason: String) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard isRunningUnderXCTest(env) else { return }
|
||||
guard !uiTestForegroundActivationInFlight else { return }
|
||||
|
||||
uiTestForegroundActivationInFlight = true
|
||||
attemptUITestForegroundActivation(reason: reason, attempt: 0)
|
||||
}
|
||||
|
||||
private func attemptUITestForegroundActivation(reason: String, attempt: Int) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard isRunningUnderXCTest(env) else {
|
||||
uiTestForegroundActivationInFlight = false
|
||||
return
|
||||
}
|
||||
|
||||
if NSApp.activationPolicy() != .regular {
|
||||
_ = NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
if NSApp.isHidden {
|
||||
NSApp.unhide(nil)
|
||||
}
|
||||
|
||||
let windows = NSApp.windows
|
||||
if let window = windows.first {
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
writeUITestDiagnosticsIfNeeded(stage: "foreground.\(reason).\(attempt)")
|
||||
|
||||
if NSApp.isActive {
|
||||
uiTestForegroundActivationInFlight = false
|
||||
return
|
||||
}
|
||||
guard attempt < 20 else {
|
||||
uiTestForegroundActivationInFlight = false
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.attemptUITestForegroundActivation(reason: reason, attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func debugActivationPolicyDescription(_ policy: NSApplication.ActivationPolicy) -> String {
|
||||
switch policy {
|
||||
case .regular:
|
||||
return "regular"
|
||||
case .accessory:
|
||||
return "accessory"
|
||||
case .prohibited:
|
||||
return "prohibited"
|
||||
@unknown default:
|
||||
return "unknown(\(policy.rawValue))"
|
||||
}
|
||||
}
|
||||
|
||||
private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"],
|
||||
|
|
@ -2677,7 +2604,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
window.setFrame(frame, display: true, animate: false)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.orderFrontRegardless()
|
||||
scheduleUITestForegroundActivationIfNeeded(reason: "afterMoveToTargetDisplay")
|
||||
if window.screen?.cmuxDisplayID != targetDisplayID, attempt < 20 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
final class DisplayResolutionRegressionUITests: XCTestCase {
|
||||
private let defaultDisplayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
|
||||
private let appBundleIdentifier = "com.cmuxterm.app.debug"
|
||||
private var launchTag = ""
|
||||
private var diagnosticsPath = ""
|
||||
private var displayReadyPath = ""
|
||||
|
|
@ -13,7 +11,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
private var displayDonePath = ""
|
||||
private var helperBinaryPath = ""
|
||||
private var helperLogPath = ""
|
||||
private var launchedRunningApp: NSRunningApplication?
|
||||
private var launchedApp: XCUIApplication?
|
||||
private var helperProcess: Process?
|
||||
|
||||
override func setUp() {
|
||||
|
|
@ -241,79 +239,18 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
helperProcess = proc
|
||||
}
|
||||
|
||||
// Launch the app via NSWorkspace instead of XCUIApplication to avoid
|
||||
// the 60-second foreground activation timeout that kills UI tests on
|
||||
// headless CI runners. NSWorkspace.openApplication passes environment
|
||||
// variables through OpenConfiguration and returns immediately.
|
||||
private func launchAppProcess(targetDisplayID: String) throws {
|
||||
let appBundlePath = try resolveAppBundlePath()
|
||||
let appURL = URL(fileURLWithPath: appBundlePath)
|
||||
|
||||
let config = NSWorkspace.OpenConfiguration()
|
||||
config.environment = launchEnvironment(targetDisplayID: targetDisplayID)
|
||||
config.activates = true
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var launchError: Error?
|
||||
var runningApp: NSRunningApplication?
|
||||
|
||||
NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in
|
||||
runningApp = app
|
||||
launchError = error
|
||||
semaphore.signal()
|
||||
let app = XCUIApplication()
|
||||
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
|
||||
app.launchEnvironment[key] = value
|
||||
}
|
||||
|
||||
let waitResult = semaphore.wait(timeout: .now() + 30.0)
|
||||
if waitResult == .timedOut {
|
||||
app.launch()
|
||||
guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "NSWorkspace.openApplication timed out after 30s for \(appBundlePath)"
|
||||
NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
|
||||
])
|
||||
}
|
||||
|
||||
if let error = launchError {
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "NSWorkspace.openApplication failed: \(error.localizedDescription) path=\(appBundlePath)"
|
||||
])
|
||||
}
|
||||
|
||||
launchedRunningApp = runningApp
|
||||
|
||||
if !waitForAppLaunchDiagnostics(timeout: 15.0) {
|
||||
let isAlive = launchedRunningApp?.isTerminated == false
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:])"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAppBundlePath() throws -> String {
|
||||
let testBundle = Bundle(for: Self.self)
|
||||
// UI test bundle is at:
|
||||
// .../Build/Products/Debug/cmuxUITests-Runner.app/Contents/PlugIns/cmuxUITests.xctest
|
||||
// The app is at:
|
||||
// .../Build/Products/Debug/cmux DEV.app
|
||||
let productsDir = testBundle.bundleURL
|
||||
.deletingLastPathComponent() // PlugIns/
|
||||
.deletingLastPathComponent() // Contents/
|
||||
.deletingLastPathComponent() // cmuxUITests-Runner.app/
|
||||
let appPath = productsDir.appendingPathComponent("cmux DEV.app").path
|
||||
if FileManager.default.fileExists(atPath: appPath) {
|
||||
return appPath
|
||||
}
|
||||
|
||||
// Fallback: search DerivedData for the app
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty {
|
||||
let candidate = URL(fileURLWithPath: builtProductsDir)
|
||||
.appendingPathComponent("cmux DEV.app").path
|
||||
if FileManager.default.fileExists(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "App bundle not found. primary=\(appPath) BUILT_PRODUCTS_DIR=\(env["BUILT_PRODUCTS_DIR"] ?? "<unset>")"
|
||||
])
|
||||
launchedApp = app
|
||||
}
|
||||
|
||||
private func launchEnvironment(targetDisplayID: String) -> [String: String] {
|
||||
|
|
@ -327,25 +264,31 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func terminateLaunchedAppIfNeeded() {
|
||||
guard let app = launchedRunningApp else { return }
|
||||
defer { launchedRunningApp = nil }
|
||||
guard let launchedApp else { return }
|
||||
defer { self.launchedApp = nil }
|
||||
|
||||
if app.isTerminated { return }
|
||||
if launchedApp.state == .notRunning {
|
||||
return
|
||||
}
|
||||
|
||||
app.terminate()
|
||||
// Wait up to 5s for termination
|
||||
let deadline = Date().addingTimeInterval(5.0)
|
||||
while !app.isTerminated && Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
if !app.isTerminated {
|
||||
app.forceTerminate()
|
||||
}
|
||||
launchedApp.terminate()
|
||||
_ = launchedApp.wait(for: .notRunning, timeout: 5.0)
|
||||
}
|
||||
|
||||
private func launchedAppDiagnostics() -> String {
|
||||
guard let app = launchedRunningApp else { return "not-launched" }
|
||||
return "pid=\(app.processIdentifier) terminated=\(app.isTerminated)"
|
||||
guard let launchedApp else { return "not-launched" }
|
||||
return "state=\(launchedApp.state.rawValue)"
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool {
|
||||
|
|
@ -412,15 +355,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
private func waitForAppLaunchDiagnostics(timeout: TimeInterval) -> Bool {
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let diagnostics = self.loadDiagnostics() else { return false }
|
||||
guard let pid = diagnostics["pid"], !pid.isEmpty else { return false }
|
||||
guard let stage = diagnostics["stage"], !stage.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTestArtifacts() {
|
||||
for path in [
|
||||
diagnosticsPath,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue