Add mock update feed UI tests

This commit is contained in:
Lawrence Chen 2026-01-28 03:22:37 -08:00
parent ca9c680da7
commit 03ee628fb6
5 changed files with 120 additions and 0 deletions

45
.github/workflows/ci.yml vendored Normal file
View file

@ -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

View file

@ -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.

View file

@ -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 {

View file

@ -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
}

View file

@ -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