TestFlight / Build and upload (push) Successful in 1m49s
Grow row height and spacing with the existing entrance animation, remove the Latest overlay while retaining scroll/read/send behavior, and reuse the current PWA icon artwork. Cover occupied-height interpolation and scroll-to-latest navigation in focused checks.
247 lines
13 KiB
Swift
247 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|