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 scrollToLatest(_ latest: XCUIElement) { for _ in 0..<30 { if latest.isHittable { break } transcript.swipeUp(velocity: .fast) } // Finish at the physical end, including the reply metadata and spacing. transcript.swipeUp(velocity: .slow) XCTAssertTrue(latest.isHittable) XCTAssertFalse(app.buttons["agent-jump-latest"].exists) } 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]) } private func reloadMixedHistory() async throws { app.terminate() let rows = (1...160).map { index in let short = "History message \(index): local simulator fixture." return ["role": index % 3 == 0 ? "user" : "assistant", "content": index == 160 || index % 3 == 0 ? short : "### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) + "- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"] } try await control(["action": "presentation-samples", "messages": rows]) app.launch() } func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws { try await reloadMixedHistory() openAgent() let latest = app.staticTexts["History message 160: local simulator fixture."] XCTAssertTrue(latest.isHittable) XCTAssertLessThan(latest.frame.maxY, composer.frame.minY) capture("agent-initial-open") showSidebar() app.buttons["Settings"].tap() XCTAssertFalse(composer.exists) openAgent() XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows") XCTAssertLessThan(latest.frame.maxY, composer.frame.minY) capture("agent-tab-return") } func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws { try await reloadMixedHistory() openAgent() composer.tap() if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" { composer.press(forDuration: 1) let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch if selectAll.waitForExistence(timeout: 2) { selectAll.tap() } else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) } composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count)) } let draft = "Keyboard send\nsecond line\nthird line" composer.typeText(draft) XCTAssertTrue(app.keyboards.firstMatch.exists) let multilineHeight = composer.frame.height app.buttons["agent-send"].tap() let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer) await fulfillment(of: [cleared], timeout: 5) XCTAssertLessThan(composer.frame.height, multilineHeight) XCTAssertTrue(app.keyboards.firstMatch.exists) let sent = app.staticTexts[draft] XCTAssertTrue(sent.waitForExistence(timeout: 5)) XCTAssertTrue(sent.isHittable) let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply) await fulfillment(of: [followed], timeout: 10) capture("agent-send-cleared-composer-with-keyboard") XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64) XCTAssertLessThan(reply.frame.maxY, composer.frame.minY) // Finished: is only the first Markdown paragraph. Measure the final // reply content, leaving the same allowance for metadata and spacing. let replyEnd = app.staticTexts["The local fixture returned three matches."] XCTAssertTrue(replyEnd.isHittable) XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY) let bottomGap = composer.frame.minY - replyEnd.frame.maxY print("Agent send settled final-content gap: \(bottomGap)pt") XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom") XCTAssertTrue(app.buttons["agent-attach"].isHittable) try await Task.sleep(for: .milliseconds(500)) let read = try await control(["action": "state"]) XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int) } 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) XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable) 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) XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable) XCTAssertFalse(app.buttons["agent-jump-latest"].exists) // 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"]) // The removed button no longer exposes unread state. Allow the entrance // and read debounce to settle, then inspect the fixture's actual cursor. try await Task.sleep(for: .milliseconds(500)) 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") let latest = app.staticTexts["Fade regression reply while reading"] scrollToLatest(latest) 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; scrolling 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) scrollToLatest(keyboardReply) XCTAssertTrue(keyboardReply.isHittable) composer.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(keyboardReply.isHittable) } }