import XCTest import Foundation final class BrowserOmnibarSuggestionsUITests: XCTestCase { private var dataPath = "" override func setUp() { super.setUp() continueAfterFailure = false dataPath = "/tmp/cmux-ui-test-omnibar-suggestions-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) // Terminate any lingering app from a prior test so its debounced // history-save doesn't overwrite the seeded browser_history.json. let cleanup = XCUIApplication() cleanup.terminate() RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } func testOmnibarSuggestionsAlignToPillAndCmdNP() { 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" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" 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" launchAndEnsureForeground(app) // Focus omnibar. app.typeKey("l", modifierFlags: [.command]) let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch XCTAssertTrue(pill.waitForExistence(timeout: 6.0)) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) // Type a query that matches the seeded URL. 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 XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch XCTAssertTrue(row0.waitForExistence(timeout: 6.0)) // Frame checks (screen coordinates). let pillFrame = pill.frame let suggestionsFrame = suggestionsElement.frame attachElementDebug(name: "omnibar-pill", element: pill) attachElementDebug(name: "omnibar-suggestions", element: suggestionsElement) XCTAssertGreaterThan(pillFrame.width, 50) XCTAssertGreaterThan(suggestionsFrame.width, 50) let xTolerance: CGFloat = 3.0 let wTolerance: CGFloat = 3.0 XCTAssertLessThanOrEqual(abs(pillFrame.minX - suggestionsFrame.minX), xTolerance, "Expected suggestions minX to match omnibar minX.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)") XCTAssertLessThanOrEqual(abs(pillFrame.width - suggestionsFrame.width), wTolerance, "Expected suggestions width to match omnibar width.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)") XCTAssertGreaterThanOrEqual( suggestionsFrame.minY, pillFrame.maxY - 1.0, "Expected suggestions popup to render below (not behind) the omnibar.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)" ) // Row 0 should be the autocompletable example.com history entry. // Verify Cmd+N moves to row 1, Cmd+P returns to row 0, then Enter navigates. let row1 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.1").firstMatch XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) 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( 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: []) // After committing the autocompletion candidate, the omnibar should contain the URL. // Note: example.com may redirect to example.org in some environments. XCTAssertTrue( waitForCondition(timeout: 8.0) { self.containsExampleDomain((omnibar.value as? String) ?? "") }, "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))" ) } func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() { seedBrowserHistoryForTest() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath // Keep suggestions deterministic. app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) XCTAssertTrue( focusOmnibarWithCmdL(app: app, omnibar: omnibar, timeout: 4.0), "Expected Cmd+L to place keyboard focus in omnibar before typing" ) // Focus omnibar and navigate to example.com via autocompletion (row 0). omnibar.typeText("exam") let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) // Row 0 is the autocompletion candidate (example.com). Enter commits it. app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) // Note: example.com may redirect to example.org in some environments. XCTAssertTrue( waitForCondition(timeout: 8.0) { self.containsExampleDomain((omnibar.value as? String) ?? "") }, "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))" ) XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? "")) // Type a new query to open the popup, then Escape should revert to the current URL. app.typeKey("l", modifierFlags: [.command]) omnibar.typeText("meaning") XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) let reverted = (omnibar.value as? String) ?? "" XCTAssertTrue(containsExampleDomain(reverted), "Expected Escape to revert omnibar to current URL. value=\(reverted)") XCTAssertFalse(suggestionsElement.waitForExistence(timeout: 0.5), "Expected Escape to close suggestions popup") // Second Escape should blur to the web view: typing should not change the omnibar value. app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) let beforeTyping = (omnibar.value as? String) ?? "" app.typeText("zzz") let afterTyping = (omnibar.value as? String) ?? "" XCTAssertEqual(afterTyping, beforeTyping, "Expected typing after 2nd Escape to not modify omnibar (blurred)") // Click outside should also discard edits and blur. app.typeKey("l", modifierFlags: [.command]) omnibar.typeText("foo") let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 6.0)) window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).click() // Give SwiftUI focus a moment to settle. RunLoop.current.run(until: Date().addingTimeInterval(0.2)) let afterClick = (omnibar.value as? String) ?? "" if !containsExampleDomain(afterClick) { // VM UI automation can occasionally keep focus in the text field after a coordinate click. // Fall back to Escape so we still validate post-click revert/blur behavior. app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) } let recoveredAfterClick = (omnibar.value as? String) ?? "" XCTAssertTrue(containsExampleDomain(recoveredAfterClick), "Expected click-outside path to discard edits. value=\(recoveredAfterClick)") app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) let beforeOutsideTyping = (omnibar.value as? String) ?? "" app.typeText("bbb") let afterOutsideTyping = (omnibar.value as? String) ?? "" XCTAssertEqual(afterOutsideTyping, beforeOutsideTyping, "Expected typing after click-outside to not modify omnibar (blurred)") } func testOmnibarSuggestionsCmdNPWhenAddressBarFocused() { seedBrowserHistoryForTest() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" 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.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) 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)) let row1 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.1").firstMatch let row2 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.2").firstMatch XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) XCTAssertTrue(row2.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) 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( 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( waitForSuggestionRowToBeSelected(row1, timeout: 3.0), "Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))" ) } func testOmnibarShowsMultipleRowsWithoutClipping() { seedBrowserHistoryForTest() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" 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.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) omnibar.typeText("go") let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) let row2 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.2").firstMatch XCTAssertTrue(row2.waitForExistence(timeout: 6.0), "Expected at least 3 suggestion rows for 'go'") let popupFrame = suggestionsElement.frame let row2Frame = row2.frame XCTAssertGreaterThan(row2Frame.height, 1, "Expected third row to have a non-zero visible height") XCTAssertLessThanOrEqual(row2Frame.maxY, popupFrame.maxY + 1, "Expected third row to stay inside popup bounds") } func testCmdLRefocusAfterNavigationKeepsOmnibarEditable() { seedBrowserHistoryForTest() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" 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" launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) // Start a real navigation, then re-focus the omnibar immediately. omnibar.typeText("example.com") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) app.typeKey("l", modifierFlags: [.command]) // Wait for navigation to finish so we can verify focus is held through page load. var loadObserved = false loadObserved = waitForCondition(timeout: 8.0) { ((omnibar.value as? String) ?? "").lowercased().contains("example.com") } XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)") let valueAfterLoad = (omnibar.value as? String) ?? "" omnibar.typeText("zx") var valueCaptured = false valueCaptured = waitForCondition(timeout: 5.0) { let value = (omnibar.value as? String) ?? "" return value.contains("zx") && value != valueAfterLoad } XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))") let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue( suggestionsElement.waitForExistence(timeout: 3.0), "Expected omnibar suggestions to appear while focused after Cmd+L during navigation" ) // Avoid leaving test in partially edited state. app.typeKey("a", modifierFlags: [.command]) app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) app.typeKey("x", modifierFlags: []) } func testCmdLImmediateTypingReplacesExistingURLBuffer() { seedBrowserHistoryForTest() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" 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" launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) // Navigate to a non-empty URL first so Cmd+L must replace existing text. app.typeKey("l", modifierFlags: [.command]) omnibar.typeText("example.com") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) let loaded = waitForCondition(timeout: 8.0) { let value = ((omnibar.value as? String) ?? "").lowercased() return self.containsExampleDomain(value) } XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") // Reproduce user flow: Cmd+L then immediate typing without waiting. app.typeKey("l", modifierFlags: [.command]) app.typeText("lo") var observedValue = "" let startsWithTypedPrefix = waitForCondition(timeout: 7.0) { observedValue = ((omnibar.value as? String) ?? "").lowercased() return observedValue.hasPrefix("lo") } XCTAssertTrue( startsWithTypedPrefix, "Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" ) } func testOmnibarAutocompleteCandidateIsCommittedOnEnter() { seedBrowserHistoryForTest( seedEntries: [ SeedEntry(url: "https://news.ycombinator.com/", title: "News Y Combinator", visitCount: 12, typedCount: 1), SeedEntry(url: "https://gmail.com/", title: "Gmail", visitCount: 10, typedCount: 2), ] ) let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" 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" launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) omnibar.typeText("gm") let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) let rows: [XCUIElement] = (0...4).map { app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.\($0)").firstMatch } XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0)) var gmailRowIndex: Int? _ = waitForCondition(timeout: 4.0) { for (index, row) in rows.enumerated() where row.exists { let rowValue = (row.value as? String) ?? "" if rowValue.localizedCaseInsensitiveContains("gmail") { gmailRowIndex = index return true } } return false } guard let gmailRowIndex else { let rowValues = rows.enumerated().compactMap { index, row -> String? in guard row.exists else { return nil } return "row\(index)=\((row.value as? String) ?? "")" }.joined(separator: ", ") XCTFail("Expected a Gmail suggestion row. rows=\(rowValues)") return } if gmailRowIndex > 0 { let gmailRow = rows[gmailRowIndex] for _ in 0.. typedPrefix.utf16.count } XCTAssertTrue( sawInlineCompletion, "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) app.typeKey("a", modifierFlags: [.command]) RunLoop.current.run(until: Date().addingTimeInterval(0.25)) let afterCmdA = (omnibar.value as? String) ?? "" XCTAssertTrue( 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 var visitCount: Int = 2 var typedCount: Int = 0 } private func seedBrowserHistoryForTest(entries: [(String, String)]? = nil, seedEntries: [SeedEntry]? = nil) { // Keep the test hermetic: write a deterministic history file in the app's support dir // so the omnibar always has at least one local suggestion row. let fileManager = FileManager.default guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { XCTFail("Missing Application Support directory") return } let bundleId = "com.cmuxterm.app.debug" let dir = appSupport.appendingPathComponent(bundleId, isDirectory: true) let url = dir.appendingPathComponent("browser_history.json", isDirectory: false) do { try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) } catch { XCTFail("Failed to create app support dir: \(error)") return } let now = Date().timeIntervalSinceReferenceDate let resolved: [SeedEntry] if let seedEntries { resolved = seedEntries } else if let entries { resolved = entries.map { SeedEntry(url: $0.0, title: $0.1, visitCount: 10, typedCount: 2) } } else { resolved = [ SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 10, typedCount: 2), SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 10, typedCount: 2), SeedEntry(url: "https://www.google.com/", title: "Google", visitCount: 10, typedCount: 2), ] } let entriesJSON = resolved.enumerated().reversed().map { index, entry in let recencyOffset = index * 120 var json = """ { "id": "\(UUID().uuidString)", "url": "\(entry.url)", "title": "\(entry.title)", "lastVisited": \(now - Double(recencyOffset)), "visitCount": \(entry.visitCount) """ if entry.typedCount > 0 { json += """ , "typedCount": \(entry.typedCount), "lastTypedAt": \(now - Double(recencyOffset)) """ } json += "\n }" return json }.joined(separator: ",\n") let json = """ [ \(entriesJSON) ] """ do { try json.write(to: url, atomically: true, encoding: .utf8) } catch { XCTFail("Failed to write browser history seed file: \(error)") } } private func attachElementDebug(name: String, element: XCUIElement) { let payload = """ identifier: \(element.identifier) label: \(element.label) exists: \(element.exists) hittable: \(element.isHittable) frame: \(element.frame) """ let attachment = XCTAttachment(string: payload) attachment.name = name attachment.lifetime = .keepAlways add(attachment) } private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { self.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 { let attempts = max(1, Int(ceil(timeout))) for _ in 0.. Bool { value.contains("example.com") || value.contains("example.org") } private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in predicate() }, object: nil ) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } }