import XCTest /// Settings → Gateway, on sample data (`-echo.demoGateway`), so it needs no server: the status, /// an MCP server switched and tested, a restart confirmed and followed to its end, an update /// found, and the logs with a filter. @MainActor final class GatewayUITests: XCTestCase { private func openGateway() -> XCUIApplication { let app = XCUIApplication() app.launchArguments = XCUIApplication.straightToChat + ["-echo.demoGateway", "-echo.screen", "settings"] app.launch() XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 20), "Settings didn't open") app.openSettingsPage("Agent") let row = app.buttons["Gateway"] for _ in 0 ..< 12 where !(row.exists && row.isHittable) { app.swipeUp() } XCTAssertTrue(row.exists && row.isHittable, "No Gateway row in Settings") row.tap() XCTAssertTrue(app.navigationBars["Gateway"].waitForExistence(timeout: 10), "The Gateway screen didn't open") return app } private func shot(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) attachment.name = name attachment.lifetime = .keepAlways add(attachment) } /// Something on the screen says `text`, as its label or as its value: a row made of a title /// and a value is one element, with the value beside the title. private func shows(_ text: String, in app: XCUIApplication, timeout: TimeInterval = 5) -> Bool { app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)) .firstMatch.waitForExistence(timeout: timeout) } private func reach(_ element: XCUIElement, in app: XCUIApplication, _ what: String) { for _ in 0 ..< 10 where !(element.exists && element.isHittable) { app.swipeUp() } XCTAssertTrue(element.exists && element.isHittable, "\(what) isn't on the screen") } func testTheServerItsMCPServersAndARestart() { continueAfterFailure = false let app = openGateway() XCTAssertTrue(shows("0.21.5 · 2026.9.24", in: app, timeout: 10), "The version isn't shown") XCTAssertTrue(shows("homelab", in: app), "The host isn't named") XCTAssertTrue(shows("Running", in: app), "The gateway's state isn't shown") shot("gateway status") // An MCP server that is off: on, and tested. let github = app.switches["github"] reach(github, in: app, "The github server's switch") XCTAssertEqual(github.value as? String, "0", "github should start off") let knob = github.switches.firstMatch (knob.exists ? knob : github).tap() XCTAssertTrue(github.wait(for: \.value, toEqual: "1", timeout: 5) || github.value as? String == "1", "The switch didn't turn on") let test = app.buttons["Test basic-memory"] reach(test, in: app, "The Test button") test.tap() XCTAssertTrue(shows("Connected · 4 tools", in: app), "The test's result isn't shown") shot("gateway MCP servers") // A restart asks first, then is followed to its end. let restart = app.buttons["Restart Gateway…"] reach(restart, in: app, "Restart Gateway") restart.tap() let confirm = app.buttons["Restart"] XCTAssertTrue(confirm.waitForExistence(timeout: 5), "No confirmation before a restart") XCTAssertTrue(shows("1 turn is running now", in: app), "The confirmation doesn't say what is running") shot("gateway restart confirmation") confirm.tap() XCTAssertTrue(shows("The gateway was restarted.", in: app, timeout: 15), "The restart's end isn't reported") // An update is found and offered, and asks first too. let check = app.buttons["Check for Updates"] reach(check, in: app, "Check for Updates") check.tap() XCTAssertTrue(shows("12 changes behind", in: app), "The update check's answer isn't shown") let update = app.buttons["Update Hermes…"] reach(update, in: app, "Update Hermes") update.tap() XCTAssertTrue(app.buttons["Update Hermes"].waitForExistence(timeout: 5), "No confirmation before an update") XCTAssertTrue(shows("changes made by hand", in: app), "The confirmation doesn't warn about local changes") shot("gateway update confirmation") app.buttons["Update Hermes"].tap() XCTAssertTrue(shows("Hermes is now 0.21.6.", in: app, timeout: 15), "The update's end isn't reported") shot("gateway after the update") } func testTheLogsAndTheirFilter() { continueAfterFailure = false let app = openGateway() let logs = app.buttons["Logs"] reach(logs, in: app, "The Logs row") logs.tap() XCTAssertTrue(app.navigationBars["Logs"].waitForExistence(timeout: 10), "The log screen didn't open") let info = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Gateway running with 2 platform'")).firstMatch XCTAssertTrue(info.waitForExistence(timeout: 5), "No log lines") shot("gateway logs") app.buttons["Filter"].tap() let errorsOnly = app.buttons["Errors only"] XCTAssertTrue(errorsOnly.waitForExistence(timeout: 5), "No level filter") errorsOnly.tap() let error = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Gateway closed the connection'")).firstMatch XCTAssertTrue(error.waitForExistence(timeout: 5), "The error line is gone") XCTAssertFalse(info.exists, "An info line is still shown with only errors asked for") } } private extension XCUIElement { /// Waits for a property to take a value; true if it did. func wait(for keyPath: KeyPath, toEqual wanted: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if self[keyPath: keyPath] as? String == wanted { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } return false } }