diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff5fdb4c..c8101096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -478,38 +478,173 @@ jobs: sleep $((attempt * 5)) done + - name: Build for testing (display resolution) + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + build-for-testing + - name: Run display resolution churn UI regression run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" HELPER_PATH="/tmp/create-virtual-display" - MANIFEST_PATH="/tmp/cmux-ui-test-display-harness.json" + TOKEN="$(uuidgen)" + DIAG_PATH="/tmp/cmux-ui-test-display-churn-${TOKEN}.json" + DISPLAY_READY="/tmp/cmux-ui-test-display-${TOKEN}.ready" + DISPLAY_ID_PATH="/tmp/cmux-ui-test-display-${TOKEN}.id" + DISPLAY_START="/tmp/cmux-ui-test-display-${TOKEN}.start" + DISPLAY_DONE="/tmp/cmux-ui-test-display-${TOKEN}.done" + HELPER_LOG="/tmp/cmux-ui-test-display-${TOKEN}-helper.log" - rm -f "$MANIFEST_PATH" - trap 'rm -f "$MANIFEST_PATH"' EXIT + cleanup() { + pkill -x "cmux DEV" 2>/dev/null || true + rm -f "$DIAG_PATH" "$DISPLAY_READY" "$DISPLAY_ID_PATH" "$DISPLAY_START" "$DISPLAY_DONE" "$HELPER_LOG" + rm -f /tmp/cmux-ui-test-prelaunch.json /tmp/cmux-ui-test-display-harness.json + } + trap cleanup EXIT + # Build display helper clang -framework Foundation -framework CoreGraphics \ -o "$HELPER_PATH" scripts/create-virtual-display.m - cat >"$MANIFEST_PATH" </dev/null || true) + if [ -z "$APP_BINARY" ]; then + echo "ERROR: App binary not found in DerivedData" >&2 + exit 1 + fi + echo "App binary: $APP_BINARY" for attempt in 1 2; do + cleanup 2>/dev/null || true + + # Launch display helper from shell (non-sandboxed) + "$HELPER_PATH" \ + --modes "1920x1080,1728x1117,1600x900,1440x810" \ + --ready-path "$DISPLAY_READY" \ + --display-id-path "$DISPLAY_ID_PATH" \ + --start-path "$DISPLAY_START" \ + --done-path "$DISPLAY_DONE" \ + --iterations 40 \ + --interval-ms 40 \ + > "$HELPER_LOG" 2>&1 & + HELPER_PID=$! + + # Wait for display ready + echo "Waiting for virtual display..." + for i in $(seq 1 24); do + if [ -f "$DISPLAY_READY" ]; then break; fi + sleep 0.5 + done + if [ ! -f "$DISPLAY_READY" ]; then + echo "ERROR: Virtual display not ready after 12s" >&2 + cat "$HELPER_LOG" 2>/dev/null || true + continue + fi + DISPLAY_ID=$(cat "$DISPLAY_ID_PATH") + echo "Virtual display ready: ID=$DISPLAY_ID" + + # Launch app from shell (non-sandboxed, outside XCTest sandbox) + CMUX_UI_TEST_MODE=1 \ + CMUX_UI_TEST_DIAGNOSTICS_PATH="$DIAG_PATH" \ + CMUX_UI_TEST_DISPLAY_RENDER_STATS=1 \ + CMUX_UI_TEST_TARGET_DISPLAY_ID="$DISPLAY_ID" \ + CMUX_TAG="ui-tests-display-resolution" \ + "$APP_BINARY" > /tmp/cmux-ui-test-app.log 2>&1 & + APP_PID=$! + echo "App launched: PID=$APP_PID" + + # Wait for app diagnostics + echo "Waiting for app diagnostics..." + APP_READY=false + for i in $(seq 1 30); do + if [ -f "$DIAG_PATH" ]; then + if python3 -c "import json; d=json.load(open('$DIAG_PATH')); assert d.get('pid')" 2>/dev/null; then + APP_READY=true + break + fi + fi + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App crashed during startup" + cat /tmp/cmux-ui-test-app.log 2>/dev/null | tail -30 || true + break + fi + sleep 0.5 + done + + if [ "$APP_READY" != "true" ]; then + echo "Attempt $attempt: App not ready after 15s" + pkill -x "cmux DEV" 2>/dev/null || true + kill "$HELPER_PID" 2>/dev/null || true + if [ "$attempt" -eq 2 ]; then + echo "Display resolution UI regression failed after 2 attempts" >&2 + echo "--- App log ---" + cat /tmp/cmux-ui-test-app.log 2>/dev/null | tail -50 || true + echo "--- Helper log ---" + cat "$HELPER_LOG" 2>/dev/null | tail -20 || true + echo "--- Diagnostics ---" + cat "$DIAG_PATH" 2>/dev/null || echo "(not found)" + exit 1 + fi + sleep 3 + continue + fi + + echo "App started. Diagnostics:" + cat "$DIAG_PATH" + + # Wait for render stats (terminal surface initialization) + echo "Waiting for render stats..." + RENDER_READY=false + for i in $(seq 1 40); do + if python3 -c "import json; d=json.load(open('$DIAG_PATH')); assert d.get('renderStatsAvailable') == '1'" 2>/dev/null; then + RENDER_READY=true + echo "Render stats available after $((i / 2))s" + break + fi + sleep 0.5 + done + if [ "$RENDER_READY" != "true" ]; then + echo "WARNING: Render stats not available after 20s. Diagnostics:" + cat "$DIAG_PATH" 2>/dev/null || true + echo "--- App log ---" + cat /tmp/cmux-ui-test-app.log 2>/dev/null | tail -30 || true + fi + + # Write manifests so test can find the pre-launched state + MANIFEST_PATH="/tmp/cmux-ui-test-display-harness.json" + cat >"$MANIFEST_PATH" <"$PRELAUNCH_PATH" </dev/null || true + kill "$HELPER_PID" 2>/dev/null || true + 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 diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index d417f0ab..73a16c2f 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -45,7 +45,17 @@ final class DisplayResolutionRegressionUITests: XCTestCase { super.tearDown() } + private let prelaunchManifestPath = "/tmp/cmux-ui-test-prelaunch.json" + func testRapidDisplayResolutionChangesKeepTerminalResponsive() throws { + // On CI, the app is pre-launched from the shell (outside the XCTest + // runner's sandbox) so it can write to /tmp/ and activate properly. + // The CI step writes a manifest with the diagnostics path. + let prelaunch = loadPrelaunchManifest() + if let diagPath = prelaunch?.diagnosticsPath, !diagPath.isEmpty { + diagnosticsPath = diagPath + } + try prepareDisplayHarnessIfNeeded() XCTAssertTrue(waitForFile(atPath: displayReadyPath, timeout: 12.0), "Expected display harness ready file at \(displayReadyPath)") @@ -54,7 +64,10 @@ final class DisplayResolutionRegressionUITests: XCTestCase { return } - try launchAppProcess(targetDisplayID: targetDisplayID) + if prelaunch == nil { + try launchAppProcess(targetDisplayID: targetDisplayID) + } + XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" @@ -470,4 +483,14 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let logPath: String? let helperBinaryPath: String? } + + private struct PrelaunchManifest: Decodable { + let diagnosticsPath: String? + } + + private func loadPrelaunchManifest() -> PrelaunchManifest? { + let url = URL(fileURLWithPath: prelaunchManifestPath) + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(PrelaunchManifest.self, from: data) + } }