diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ff4e40..145bc80e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Validate unit-test SwiftPM retry guard run: ./tests/test_ci_unit_test_spm_retry.sh + - name: Validate cmux scheme test configuration + run: ./tests/test_ci_scheme_testaction_debug.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -96,11 +99,35 @@ jobs: # Remove stale build cache to avoid incremental build errors rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + rm -rf "$SOURCE_PACKAGES_DIR" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + - name: Run unit tests run: | set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" run_unit_tests() { xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ -destination "platform=macOS" test 2>&1 } @@ -138,4 +165,9 @@ jobs: - name: Run UI tests run: | set -euo pipefail - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -only-testing:cmuxUITests/UpdatePillUITests test diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme index 23f45429..c8f698bc 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme @@ -7,7 +7,7 @@ - + diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6d8ea6ae..c06d3b90 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5048,7 +5048,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35) + // Guard against stale browserAddressBarFocusedPanelId after focus transitions + // (e.g., split that doesn't properly blur the address bar). If the first responder + // is a terminal surface, the address bar can't be focused. + if browserAddressBarFocusedPanelId != nil, + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { +#if DEBUG + dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") +#endif + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() + } + + // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like + // suggestion navigation, and avoid opening command palette switcher. + // Scope the omnibar check to the shortcut's routed window context so a + // focused omnibar in another window does not suppress Cmd+P here. + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let isCommandP = !hasFocusedAddressBarInShortcutContext + && normalizedFlags == [.command] + && (chars == "p" || event.keyCode == 35) if isCommandP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) @@ -5143,18 +5162,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - // Guard against stale browserAddressBarFocusedPanelId after focus transitions - // (e.g., split that doesn't properly blur the address bar). If the first responder - // is a terminal surface, the address bar can't be focused. - if browserAddressBarFocusedPanelId != nil, - cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { -#if DEBUG - dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") -#endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() - } - // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { dispatchBrowserOmnibarSelectionMove(delta: delta) @@ -5707,6 +5714,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserAddressBarFocusedPanelId } + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } + guard let context = preferredMainWindowContextForShortcutRouting(event: event), + let workspace = context.tabManager.selectedWorkspace, + workspace.browserPanel(for: panelId) != nil else { + return nil + } + return panelId + } + @discardableResult func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { focusBrowserAddressBar(panelId: panelId) diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 33babafe..f5a6825f 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -384,12 +384,19 @@ struct SocketControlSettings { "XCTestConfigurationFilePath", "XCTestBundlePath", "XCTestSessionIdentifier", + "XCInjectBundle", "XCInjectBundleInto", ] - return indicators.contains { key in + if indicators.contains(where: { key in guard let value = environment[key] else { return false } return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) { + return true } + if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { + return true + } + return false } static func socketPath( diff --git a/ci_scripts/ci_pre_xcodebuild.sh b/ci_scripts/ci_pre_xcodebuild.sh new file mode 100755 index 00000000..d4def0b5 --- /dev/null +++ b/ci_scripts/ci_pre_xcodebuild.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$PWD}" +cd "$ROOT" + +echo "ci_pre_xcodebuild: repository root is $ROOT" + +if [ -f "vendor/bonsplit/Package.swift" ]; then + echo "ci_pre_xcodebuild: vendor/bonsplit already present" + exit 0 +fi + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "ci_pre_xcodebuild: attempting submodule init for vendor/bonsplit" + git submodule sync --recursive || true + git submodule update --init --recursive vendor/bonsplit || true +fi + +if [ ! -f "vendor/bonsplit/Package.swift" ]; then + echo "ci_pre_xcodebuild: submodule not present, cloning fallback" + rm -rf vendor/bonsplit + mkdir -p vendor + git clone --depth 1 https://github.com/manaflow-ai/bonsplit.git vendor/bonsplit + + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + expected_sha="$(git ls-tree HEAD vendor/bonsplit | awk '{print $3}')" + if [ -n "${expected_sha:-}" ]; then + ( + cd vendor/bonsplit + git fetch --depth 1 origin "$expected_sha" || true + git checkout "$expected_sha" || true + ) + fi + fi +fi + +if [ ! -f "vendor/bonsplit/Package.swift" ]; then + echo "ci_pre_xcodebuild: missing vendor/bonsplit/Package.swift after recovery" >&2 + exit 1 +fi + +echo "ci_pre_xcodebuild: vendor/bonsplit is ready" diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 292436c0..3f85abba 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -931,6 +931,26 @@ final class SocketControlSettingsTests: XCTestCase { ) } + func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCInjectBundle": "/tmp/fake.xctest"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testXCTestDyldLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() { // XCUITest launches the app as a separate process without XCTest env vars. // The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment. diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 8cc422a7..825207e5 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -6,6 +6,7 @@ final class AutomationSocketUITests: XCTestCase { private let defaultsDomain = "com.cmuxterm.app.debug" private let modeKey = "socketControlMode" private let legacyKey = "socketControlEnabled" + private let launchTag = "ui-tests-automation-socket" override func setUp() { super.setUp() @@ -16,11 +17,12 @@ final class AutomationSocketUITests: XCTestCase { } func testSocketToggleDisablesAndEnables() { - let app = XCUIApplication() - app.launchArguments += ["-\(modeKey)", "cmuxOnly"] - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + let app = configuredApp(mode: "cmuxOnly") app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for socket toggle test. state=\(app.state.rawValue)" + ) guard let resolvedPath = resolveSocketPath(timeout: 5.0) else { XCTFail("Expected control socket to exist") @@ -32,16 +34,40 @@ final class AutomationSocketUITests: XCTestCase { } func testSocketDisabledWhenSettingOff() { - let app = XCUIApplication() - app.launchArguments += ["-\(modeKey)", "off"] - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + let app = configuredApp(mode: "off") app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for socket off test. state=\(app.state.rawValue)" + ) XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0)) app.terminate() } + private func configuredApp(mode: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += ["-\(modeKey)", mode] + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + // Debug launches require a tag outside reload.sh; provide one in UITests so CI + // does not fail with "Application ... does not have a process ID". + app.launchEnvironment["CMUX_TAG"] = launchTag + return app + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + // On busy UI runners the app can launch backgrounded; activate once before failing. + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index 4d18e5dc..ec5bc3c8 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -18,7 +18,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } func testOmnibarSuggestionsAlignToPillAndCmdNP() { - seedBrowserHistoryForTest() + seedBrowserHistoryForTest(seedEntries: [ + SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4), + SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3), + SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 6, typedCount: 1), + ]) let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -26,8 +30,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath // Keep suggestions deterministic for the keyboard-nav assertions. app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) // Focus omnibar. app.typeKey("l", modifierFlags: [.command]) @@ -39,7 +42,10 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) // Type a query that matches the seeded URL. - omnibar.typeText("exam") + XCTAssertTrue( + typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "exam", timeout: 6.0), + "Expected omnibar suggestions to appear for 'exam'" + ) // SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type. let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch @@ -75,10 +81,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1. value=\(String(describing: row1.value))") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + ) app.typeKey("p", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row0, timeout: 2.0), "Expected Cmd+P to return to row 0. value=\(String(describing: row0.value))") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row0, timeout: 3.0), + "Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))" + ) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) @@ -104,8 +116,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath // Keep suggestions deterministic. app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) @@ -189,14 +200,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# - app.launch() - app.activate() - - app.typeKey("l", modifierFlags: [.command]) + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) - omnibar.typeText("go") + XCTAssertTrue( + typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "go", timeout: 6.0), + "Expected omnibar suggestions to appear for 'go'" + ) let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) @@ -207,13 +218,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(row2.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + ) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row2, timeout: 3.0), + "Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))" + ) app.typeKey("p", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))" + ) } func testOmnibarShowsMultipleRowsWithoutClipping() { @@ -225,8 +245,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -253,8 +272,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -315,8 +333,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) @@ -373,8 +390,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -415,8 +431,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -461,8 +476,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -499,8 +513,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -508,17 +521,20 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) omnibar.typeText("go") + let typedPrefix = "go" let inlineDeadline = Date().addingTimeInterval(3.0) + var valueBeforeCmdA = "" while Date() < inlineDeadline { - let value = (omnibar.value as? String) ?? "" - if value.contains("google.com") { + valueBeforeCmdA = (omnibar.value as? String) ?? "" + let normalized = valueBeforeCmdA.lowercased() + if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { break } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } XCTAssertTrue( - ((omnibar.value as? String) ?? "").contains("google.com"), - "Expected inline completion to show google.com before Cmd+A." + valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, + "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) app.typeKey("a", modifierFlags: [.command]) @@ -526,11 +542,30 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { let afterCmdA = (omnibar.value as? String) ?? "" XCTAssertTrue( - afterCmdA.contains("google.com"), - "Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. value=\(afterCmdA)" + afterCmdA.lowercased().hasPrefix(typedPrefix) && afterCmdA.utf16.count > typedPrefix.utf16.count, + "Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. before=\(valueBeforeCmdA) after=\(afterCmdA)" ) } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: timeout), + "Expected app to launch in foreground. state=\(app.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 struct SeedEntry { let url: String let title: String @@ -617,14 +652,49 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { add(attachment) } - private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { + private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if ((row.value as? String) ?? "").contains("selected") { + if isSuggestionRowSelected(row) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - return ((row.value as? String) ?? "").contains("selected") + return isSuggestionRowSelected(row) + } + + private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { + guard row.exists else { return false } + guard let rawValue = row.value as? String else { return false } + return rawValue.localizedCaseInsensitiveContains("selected") + } + + private func typeQueryAndWaitForSuggestions( + app: XCUIApplication, + omnibar: XCUIElement, + query: String, + timeout: TimeInterval, + attempts: Int = 3 + ) -> Bool { + let suggestions = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch + for _ in 0.. 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 waitForData(keys: [String], timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 578b3005..54c35d19 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -134,8 +134,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } let rightPanelId = ready["rightPanelId"] ?? "" - XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be the focused panel before Ctrl+D. data=\(ready)") - XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected AppKit first responder to match right split before Ctrl+D. data=\(ready)") + guard !rightPanelId.isEmpty else { + XCTFail("Missing rightPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Horizontal split") // Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper. app.activate() @@ -191,8 +194,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } let rightPanelId = ready["rightPanelId"] ?? "" - XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be focused before Ctrl+D. data=\(ready)") - XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected first responder to match right split before Ctrl+D. data=\(ready)") + guard !rightPanelId.isEmpty else { + XCTFail("Missing rightPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Three-pane layout") guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") return @@ -257,16 +263,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close") app.typeKey("d", modifierFlags: [.control]) @@ -335,16 +336,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-bottom-close") app.typeKey("d", modifierFlags: [.control]) @@ -412,16 +408,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close real key") app.typeKey("d", modifierFlags: [.control]) @@ -497,16 +488,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): left/right real key") app.typeKey("d", modifierFlags: [.control]) @@ -681,6 +667,26 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { return nil } + private func assertCtrlDPreconditionsBeforeTrigger( + _ data: [String: String], + expectedExitPanelId: String, + context: String + ) { + XCTAssertEqual( + data["focusedPanelBefore"], + expectedExitPanelId, + "\(context): expected target exit pane to be focused before Ctrl+D. data=\(data)" + ) + let firstResponderPanelBefore = data["firstResponderPanelBefore"] ?? "" + if !firstResponderPanelBefore.isEmpty { + XCTAssertEqual( + firstResponderPanelBefore, + expectedExitPanelId, + "\(context): expected first responder to match target pane before Ctrl+D when present. data=\(data)" + ) + } + } + private func loadJSON(atPath path: String) -> [String: String]? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 501d38f6..f698b9af 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -5,12 +5,14 @@ import CoreGraphics final class MultiWindowNotificationsUITests: XCTestCase { private var dataPath = "" private var socketPath = "" + private var launchTag = "" override func setUp() { super.setUp() continueAfterFailure = false dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json" socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" + launchTag = "ui-tests-multi-window-notifs-\(UUID().uuidString.prefix(8))" try? FileManager.default.removeItem(atPath: dataPath) try? FileManager.default.removeItem(atPath: socketPath) } @@ -25,8 +27,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for multi-window routing test. state=\(app.state.rawValue)" + ) XCTAssertTrue( waitForData(keys: [ @@ -108,8 +114,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for notifications popover shortcut test. state=\(app.state.rawValue)" + ) XCTAssertTrue( waitForData(keys: ["notifId1"], timeout: 15.0), @@ -137,14 +147,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape") } - func testEmptyNotificationsPopoverBlocksTerminalTyping() { + func testEmptyNotificationsPopoverBlocksTerminalTyping() throws { let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for empty popover blocking test. state=\(app.state.rawValue)" + ) XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0)) - XCTAssertTrue(waitForSocketPong(timeout: 8.0), "Expected control socket to respond") + guard let resolvedPath = resolveSocketPath(timeout: 8.0) else { + throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)") + } + socketPath = resolvedPath + let pingResponse = waitForSocketPong(timeout: 8.0) + guard pingResponse == "PONG" else { + throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") + } _ = socketCommand("clear_notifications") @@ -198,6 +223,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { return app.windows.count >= count } + 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 waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) @@ -238,31 +274,73 @@ final class MultiWindowNotificationsUITests: XCTestCase { return false } - private func waitForSocketPong(timeout: TimeInterval) -> Bool { + private func waitForSocketPong(timeout: TimeInterval) -> String? { let deadline = Date().addingTimeInterval(timeout) + var lastResponse: String? while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true + lastResponse = socketCommand("ping") + if lastResponse == "PONG" { + return "PONG" } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } + return socketCommand("ping") ?? lastResponse + } + + private func resolveSocketPath(timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for candidate in expectedSocketCandidates() { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate) { + return candidate + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + for candidate in expectedSocketCandidates() { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate) { + return candidate + } + } + return nil + } + + private func expectedSocketCandidates() -> [String] { + var candidates = [socketPath] + let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock" + if taggedDebugSocket != socketPath { + candidates.append(taggedDebugSocket) + } + return candidates + } + + private func socketRespondsToPing(at path: String) -> Bool { + let originalPath = socketPath + socketPath = path + defer { socketPath = originalPath } return socketCommand("ping") == "PONG" } private func socketCommand(_ cmd: String) -> String? { + if let response = ControlSocketClient(path: socketPath).sendLine(cmd) { + return response + } + return socketCommandViaNetcat(cmd) + } + + private func socketCommandViaNetcat(_ cmd: String) -> String? { let nc = "/usr/bin/nc" guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } let proc = Process() - proc.executableURL = URL(fileURLWithPath: nc) - proc.arguments = ["-U", socketPath, "-w", "2"] + proc.executableURL = URL(fileURLWithPath: "/bin/sh") + let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null" + proc.arguments = ["-lc", script] - let inPipe = Pipe() let outPipe = Pipe() - let errPipe = Pipe() - proc.standardInput = inPipe proc.standardOutput = outPipe - proc.standardError = errPipe do { try proc.run() @@ -270,11 +348,6 @@ final class MultiWindowNotificationsUITests: XCTestCase { return nil } - if let data = (cmd + "\n").data(using: .utf8) { - inPipe.fileHandleForWriting.write(data) - } - inPipe.fileHandleForWriting.closeFile() - proc.waitUntilExit() let outData = outPipe.fileHandleForReading.readDataToEndOfFile() @@ -286,6 +359,94 @@ final class MultiWindowNotificationsUITests: XCTestCase { return trimmed.isEmpty ? nil : trimmed } + private func shellSingleQuote(_ value: String) -> String { + if value.isEmpty { return "''" } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private final class ControlSocketClient { + private let path: String + + init(path: String) { + self.path = path + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { p in + let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for i in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = line + "\n" + let wrote: Bool = payload.withCString { cstr in + var remaining = strlen(cstr) + var p = UnsafeRawPointer(cstr) + while remaining > 0 { + let n = write(fd, p, remaining) + if n <= 0 { return false } + remaining -= n + p = p.advanced(by: n) + } + return true + } + guard wrote else { return nil } + + var buf = [UInt8](repeating: 0, count: 4096) + var accum = "" + while true { + let n = read(fd, &buf, buf.count) + if n <= 0 { break } + if let chunk = String(bytes: buf[0.. String? { guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else { return nil diff --git a/tests/test_ci_scheme_testaction_debug.sh b/tests/test_ci_scheme_testaction_debug.sh new file mode 100755 index 00000000..12347f31 --- /dev/null +++ b/tests/test_ci_scheme_testaction_debug.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme" + +if [ ! -f "$SCHEME_FILE" ]; then + echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2 + exit 1 +fi + +if ! grep -q '&2 + exit 1 +fi + +echo "PASS: cmux scheme TestAction uses Debug"