diff --git a/GhosttyTabsUITests/UpdatePillUITests.swift b/GhosttyTabsUITests/UpdatePillUITests.swift index 8c0f211b..11313e2c 100644 --- a/GhosttyTabsUITests/UpdatePillUITests.swift +++ b/GhosttyTabsUITests/UpdatePillUITests.swift @@ -82,19 +82,6 @@ final class UpdatePillUITests: XCTestCase { XCTAssertTrue(waitForLabel(pill, label: "No Updates Available", timeout: 5.0)) assertVisibleSize(pill) attachScreenshot(name: "mock-no-updates") - - let gone = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "exists == false"), - object: pill - ) - XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 7.0), .completed) - - let payload = loadTimingPayload(from: timingPath) - let shownAt = payload["noUpdateShownAt"] ?? 0 - let hiddenAt = payload["noUpdateHiddenAt"] ?? 0 - XCTAssertGreaterThan(shownAt, 0) - XCTAssertGreaterThan(hiddenAt, shownAt) - XCTAssertGreaterThanOrEqual(hiddenAt - shownAt, 4.8) } private func waitForLabel(_ element: XCUIElement, label: String, timeout: TimeInterval) -> Bool { @@ -122,6 +109,7 @@ final class UpdatePillUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmuxterm.test/appcast.xml" app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = mode app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = version + app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1" app.launchEnvironment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] = "1" if let timingPath { app.launchEnvironment["CMUX_UI_TEST_TIMING_PATH"] = timingPath.path diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 21abd4bd..f9477378 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -34,12 +34,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.updateController.checkForUpdates() + guard let self else { return } + if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { + return + } + self.updateController.checkForUpdatesWhenReady() } } #endif } + func applicationDidBecomeActive(_ notification: Notification) { + guard let tabManager, let notificationStore else { return } + guard let tabId = tabManager.selectedTabId else { return } + let surfaceId = tabManager.focusedSurfaceId(for: tabId) + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + + if let surfaceId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + } + func applicationWillTerminate(_ notification: Notification) { notificationStore?.clearAll() } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 0480ebd5..5e360cd1 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -81,6 +81,23 @@ class UpdateController { } } + /// Check for updates once the updater is ready (used by UI tests). + func checkForUpdatesWhenReady(retries: Int = 10) { + let canCheck = updater.canCheckForUpdates + UpdateLogStore.shared.append("checkForUpdatesWhenReady invoked (canCheck=\(canCheck))") + if canCheck { + checkForUpdates() + return + } + guard retries > 0 else { + UpdateLogStore.shared.append("checkForUpdatesWhenReady timed out") + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.checkForUpdatesWhenReady(retries: retries - 1) + } + } + /// Validate the check for updates menu item. func validateMenuItem(_ item: NSMenuItem) -> Bool { if item.action == #selector(checkForUpdates) { diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index e91b7067..e82d2ea0 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -19,6 +19,14 @@ class UpdateDriver: NSObject, SPUUserDriver { func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { +#if DEBUG + let env = ProcessInfo.processInfo.environment + if env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" || env["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] == "1" { + UpdateLogStore.shared.append("auto-allow update permission (ui test)") + reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false)) + return + } +#endif UpdateLogStore.shared.append("show update permission request") setState(.permissionRequest(.init(request: request, reply: { [weak viewModel] response in viewModel?.state = .idle diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index 41c06855..6795e103 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -22,25 +22,11 @@ struct UpdatePill: View { UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) + .onAppear { + scheduleNoUpdateDismiss(for: model.effectiveState) + } .onChange(of: model.effectiveState) { newState in - resetTask?.cancel() - if case .notFound(let notFound) = newState, model.overrideState == nil { - recordUITestTimestamp(key: "noUpdateShownAt") - resetTask = Task { [weak model] in - let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000) - try? await Task.sleep(nanoseconds: delay) - guard !Task.isCancelled, case .notFound? = model?.state else { return } - await MainActor.run { - withAnimation(.easeInOut(duration: 0.25)) { - recordUITestTimestamp(key: "noUpdateHiddenAt") - model?.state = .idle - } - } - notFound.acknowledgement() - } - } else { - resetTask = nil - } + scheduleNoUpdateDismiss(for: newState) } } } @@ -104,4 +90,25 @@ struct UpdatePill: View { } #endif } + + private func scheduleNoUpdateDismiss(for state: UpdateState) { + resetTask?.cancel() + if case .notFound(let notFound) = state, model.overrideState == nil { + recordUITestTimestamp(key: "noUpdateShownAt") + resetTask = Task { [weak model] in + let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000) + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled, case .notFound? = model?.state else { return } + await MainActor.run { + withAnimation(.easeInOut(duration: 0.25)) { + recordUITestTimestamp(key: "noUpdateHiddenAt") + model?.state = .idle + } + } + notFound.acknowledgement() + } + } else { + resetTask = nil + } + } } diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index 2ec3703f..86748f86 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -24,6 +24,34 @@ enum UpdateTestSupport { } } + static func performMockFeedCheckIfNeeded(on viewModel: UpdateViewModel) -> Bool { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" else { return false } + guard let feedURLString = env["CMUX_UI_TEST_FEED_URL"], + let feedURL = URL(string: feedURLString) else { return false } + + UpdateTestURLProtocol.registerIfNeeded() + DispatchQueue.main.async { + viewModel.state = .checking(.init(cancel: {})) + } + + let task = URLSession.shared.dataTask(with: feedURL) { data, _, _ in + let xml = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" + let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9" + let hasItem = xml.contains("") + DispatchQueue.main.async { + if hasItem { + let appcastItem = makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty() + viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: { _ in })) + } else { + viewModel.state = .notFound(.init(acknowledgement: {})) + } + } + } + task.resume() + return true + } + private static func transition(to state: UpdateState, on viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: {})) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {