import XCTest import UIKit /// Loopback-only regressions. No picker selection or external backend writes. @MainActor final class TranscriptFadeUITests: XCTestCase { private let app = XCUIApplication() override func setUp() async throws { continueAfterFailure = false XCUIDevice.shared.orientation = .portrait try await control(["action": "reset", "count": 160]) try await control(["action": "transcript-comparison"]) app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"] app.launch() } override func tearDown() async throws { app.terminate() } private func showSidebar() { if !app.buttons["agent-sidebar"].isHittable { XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15)) app.buttons["Show sidebar"].tap() } XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5)) } private func openAgent() { showSidebar() app.buttons["agent-sidebar"].tap() XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists) XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8)) } private var composer: XCUIElement { app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"] } private var transcript: XCUIElement { // Identify the transcript independently of any particular message row. app.scrollViews["agent-transcript"] } private func capture(_ name: String) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) } @discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] { var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!) request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value) request.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, response) = try await URLSession.shared.data(for: request) XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) } func testSharedHeaderFadeGeometry() async throws { showSidebar() let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap() // Normal chat is lazy and can open at either end on phone/tablet. let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch XCTAssertTrue(normal.waitForExistence(timeout: 8)) let normalTop = normal.frame.minY let first = app.staticTexts["History message 1: local simulator fixture."] if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) } else { normal.swipeDown(velocity: .slow) } capture("normal-chat-content-crossing-shared-fade") XCTAssertTrue(app.buttons["Attach file"].isHittable) openAgent() let scroll = transcript XCTAssertTrue(scroll.exists) scroll.swipeDown(velocity: .slow) XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5)) capture("agent-content-crossing-shared-fade") XCTAssertTrue(composer.isHittable) XCTAssertTrue(app.buttons["agent-attach"].isHittable) XCTAssertTrue(app.buttons["agent-send"].exists) // A viewport padded below the header clips bubbles before they reach its fade. XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1, "Agent must scroll beneath the same workspace header as normal chat") } func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws { openAgent() let scroll = transcript XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable) scroll.swipeDown(velocity: .slow) scroll.swipeDown(velocity: .slow) XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5)) // Stay below the header's content clearance, where text is readable. let visibleTop = scroll.frame.minY + 100 let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY }) let before = anchor.frame.minY let stateBefore = try await control(["action": "state"]) try await control(["action": "reply", "content": "Fade regression reply while reading"]) let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"]) await fulfillment(of: [unread], timeout: 8) XCTAssertEqual(anchor.frame.minY, before, accuracy: 2) let stateAfter = try await control(["action": "state"]) XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int) XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int)) capture("agent-fade-reading-position-and-unread") for _ in 0..<30 { if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break } scroll.swipeDown(velocity: .fast) } let load = app.buttons["Load earlier messages"] XCTAssertTrue(load.isHittable) load.tap() // Prepend position is intentionally not preserved with lazy history. // New-message reading anchors above and follow/read settling below remain strict. XCTAssertFalse(load.exists) capture("agent-fade-older-history-loaded") app.buttons["agent-jump-latest"].tap() let latest = app.staticTexts["Fade regression reply while reading"] let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest) await fulfillment(of: [followed], timeout: 5) composer.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(latest.isHittable) XCTAssertTrue(app.buttons["agent-attach"].isHittable) capture("agent-fade-latest-with-keyboard") // No send: this only changes a local draft, then clears it. composer.typeText("Fade draft\nsecond line\nthird line") XCTAssertTrue(latest.isHittable) composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count)) try await Task.sleep(for: .milliseconds(500)) let read = try await control(["action": "state"]) XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int) // A reply still follows and settles as read while the keyboard is up. try await control(["action": "reply", "content": "Reply with keyboard visible"]) let keyboardReply = app.staticTexts["Reply with keyboard visible"] let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply) await fulfillment(of: [keyboardFollowed], timeout: 5) try await Task.sleep(for: .milliseconds(500)) let keyboardRead = try await control(["action": "state"]) XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int) // Interactive dismissal is user navigation; Latest must still return to // the bottom, and presenting the keyboard again must keep it visible. let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)) let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)) dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd) let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch) await fulfillment(of: [dismissed], timeout: 5) if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() } XCTAssertTrue(keyboardReply.isHittable) composer.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(keyboardReply.isHittable) } }