2026-10-14 19:51:27 -07:00
|
|
|
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 {
|
2026-10-16 11:51:07 -07:00
|
|
|
// Identify the transcript independently of any particular message row.
|
|
|
|
|
app.scrollViews["agent-transcript"]
|
2026-10-14 19:51:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-16 11:51:07 -07:00
|
|
|
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
2026-10-14 19:51:27 -07:00
|
|
|
scroll.swipeDown(velocity: .fast)
|
|
|
|
|
}
|
|
|
|
|
let load = app.buttons["Load earlier messages"]
|
|
|
|
|
XCTAssertTrue(load.isHittable)
|
|
|
|
|
let first = app.staticTexts["History message 61: local simulator fixture."]
|
|
|
|
|
let beforeLoad = first.frame.minY
|
|
|
|
|
load.tap()
|
|
|
|
|
capture("agent-fade-prepend-reading-position")
|
|
|
|
|
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
|
|
|
|
"Prepending must preserve the readable row's position beneath the header")
|
|
|
|
|
|
|
|
|
|
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(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)
|
|
|
|
|
}
|
|
|
|
|
}
|