diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..706332bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + ui-tests: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Run UI tests + run: | + xcodebuild \ + -project GhosttyTabs.xcodeproj \ + -scheme cmux \ + -configuration Debug \ + -destination 'platform=macOS' \ + -only-testing:GhosttyTabsUITests/UpdatePillUITests \ + test diff --git a/CLAUDE.md b/CLAUDE.md index 77e2e0a6..54946b24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,14 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des ./scripts/reload2.sh ``` +## E2E mac UI tests + +Run UI tests on the UTM macOS VM (never on the host machine): + +```bash +ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test' +``` + ## Release Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip. diff --git a/GhosttyTabsUITests/UpdatePillUITests.swift b/GhosttyTabsUITests/UpdatePillUITests.swift index d82b3a55..8c0f211b 100644 --- a/GhosttyTabsUITests/UpdatePillUITests.swift +++ b/GhosttyTabsUITests/UpdatePillUITests.swift @@ -58,6 +58,45 @@ final class UpdatePillUITests: XCTestCase { XCTAssertGreaterThanOrEqual(hiddenAt - shownAt, 4.8) } + func testCheckForUpdatesUsesMockFeedWithUpdate() { + let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") + systemSettings.terminate() + let app = launchAppWithMockFeed(mode: "available", version: "9.9.9") + + let pill = app.descendants(matching: .any)["UpdatePill"] + XCTAssertTrue(pill.waitForExistence(timeout: 5.0)) + XCTAssertTrue(waitForLabel(pill, label: "Update Available: 9.9.9", timeout: 5.0)) + assertVisibleSize(pill) + attachScreenshot(name: "mock-update-available") + } + + func testCheckForUpdatesUsesMockFeedWithNoUpdate() { + let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") + systemSettings.terminate() + let timingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-timing-\(UUID().uuidString).json") + let app = launchAppWithMockFeed(mode: "none", version: "9.9.9", timingPath: timingPath) + + let pill = app.descendants(matching: .any)["UpdatePill"] + XCTAssertTrue(pill.waitForExistence(timeout: 5.0)) + 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 { let predicate = NSPredicate(format: "label == %@", label) let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) @@ -77,6 +116,21 @@ final class UpdatePillUITests: XCTestCase { add(attachment) } + private func launchAppWithMockFeed(mode: String, version: String, timingPath: URL? = nil) -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + 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_TRIGGER_UPDATE_CHECK"] = "1" + if let timingPath { + app.launchEnvironment["CMUX_UI_TEST_TIMING_PATH"] = timingPath.path + } + app.launch() + app.activate() + return app + } + private func loadTimingPayload(from url: URL) -> [String: Double] { guard let data = try? Data(contentsOf: url), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 345d4271..21abd4bd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -32,6 +32,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.start() #if DEBUG 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() + } + } #endif } diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 8c5ae35b..7e1f5ba0 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -3,6 +3,14 @@ import Cocoa extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { +#if DEBUG + let env = ProcessInfo.processInfo.environment + if let override = env["CMUX_UI_TEST_FEED_URL"], !override.isEmpty { + UpdateTestURLProtocol.registerIfNeeded() + recordFeedURLString(override, usedFallback: false) + return override + } +#endif let infoURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String let fallback = "https://github.com/manaflow-ai/cmuxterm/releases/latest/download/appcast.xml" let feedURLString = (infoURL?.isEmpty == false) ? infoURL! : fallback