Files
Sybil-2/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift
T
buzzert 671ee98ab1
TestFlight / Build and upload (push) Successful in 1m49s
ios: smooth Agent entrances and refresh app icon
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.
2026-10-16 16:54:46 -07:00

298 lines
16 KiB
Swift

import XCTest
import SwiftUI
@testable import Sybil
/// Run on an iPhone simulator, with the real Agent view and deterministic local
/// messages. Display-link delivery gaps measure main-run-loop responsiveness,
/// not GPU presentation, touch latency, or physical-device frame rate.
@MainActor
final class AgentPerformanceTests: XCTestCase {
func testEntranceGrowsOccupiedHeight() async throws {
XCTAssertFalse(UIAccessibility.isReduceMotionEnabled, "This check must exercise the entrance animation")
let room = SybilAgentRoom()
let rows = (1...3).map { index in
Message(id: "entrance-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000),
role: .assistant, content: "Existing neighbor \(index)")
}
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: rows,
cursor: "3", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil; room.stop() }
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(500))
func transcript(in view: UIView) -> UIScrollView? {
if let scroll = view as? UIScrollView, scroll.contentSize.height > 100 { return scroll }
return view.subviews.lazy.compactMap { transcript(in: $0) }.first
}
let scroll = try XCTUnwrap(transcript(in: host.view))
let before = scroll.contentSize.height
let reply = Message(id: "entrance-4", createdAt: rows[0].createdAt, role: .assistant,
content: "Incoming reply\n\nIts occupied space should grow with the bubble.")
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: [reply],
cursor: "4", beforeCursor: nil, hasMore: false)))
room.flush()
var heights: [CGFloat] = []
for _ in 0..<32 {
try await Task.sleep(for: .milliseconds(16))
heights.append(scroll.contentSize.height)
}
let after = try XCTUnwrap(heights.last)
// All four short rows fit onscreen, so these are measured heights, not
// estimates for unmaterialized lazy history. No frame-rate claim.
XCTAssertLessThan(after, scroll.bounds.height)
XCTAssertGreaterThan(after - before, 50)
let intermediate = Set(heights.filter { $0 > before + 2 && $0 < after - 2 }.map { Int($0.rounded()) })
XCTAssertGreaterThanOrEqual(intermediate.count, 3, "Neighbors must get intermediate layout heights, not a full-height insertion")
print("AGENT_ENTRANCE_HEIGHT before=\(before) samples=\(heights)")
}
func testThinkingScrollResponsiveness() async throws {
let room = SybilAgentRoom()
var rows: [Message] = []
for index in 1...160 { rows.append(Self.message(index)) }
try room.receive(.messages(AgentPage(conversationId: "performance", messages: rows, cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
.background(SybilTheme.background).environment(\.colorScheme, .dark))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer {
AgentRenderDiagnostics.enabled = false
window.isHidden = true
window.rootViewController = nil
room.stop()
}
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .seconds(2))
let scroll = try XCTUnwrap(findScroll(host.view))
XCTAssertGreaterThan(scroll.contentSize.height, 10_000)
// Move away from the pinned bottom before the controlled sweeps.
scroll.setContentOffset(CGPoint(x: 0, y: max(0, scroll.contentSize.height - scroll.bounds.height - 1200)), animated: false)
try await Task.sleep(for: .seconds(1))
AgentRenderDiagnostics.enabled = true
var reports: [[String: Any]] = []
for phase in ["idle-scroll", "thinking-stationary", "thinking-scroll"] {
if phase == "thinking-stationary" {
try room.receive(.activity("performance", busyActivity(step: 0)))
room.flush()
scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), animated: false)
try await Task.sleep(for: .seconds(1))
XCTAssertTrue(room.snapshot.isTyping)
}
AgentRenderDiagnostics.counts = [:]
let sampler = AgentFrameSampler(scroll: phase == "thinking-stationary" ? nil : scroll)
sampler.start()
// Changing queue/worker/tool progress every 500 ms exercises real
// activity observation while the typing spinner remains active. No IO.
for step in 1...24 {
try await Task.sleep(for: .milliseconds(500))
if phase != "idle-scroll" {
try room.receive(.activity("performance", busyActivity(step: step)))
}
}
var report = sampler.stop()
report["phase"] = phase
report["procedure"] = "mixed160-changing-activity-v1"
report["operations"] = AgentRenderDiagnostics.counts
report["contentHeight"] = scroll.contentSize.height
// iPhone 16e / iOS 26.5 / default text size reference geometry from
// the unoptimized view. A faster result must not shrink the content.
XCTAssertEqual(scroll.contentSize.height,
phase == "idle-scroll" ? 39_181.333333 : 39_223.333333,
accuracy: 1)
reports.append(report)
}
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
print("AGENT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
/// view rebuild loop or replacement composer/animation is used.
func testInputAndEntranceResponsiveness() async throws {
let room = SybilAgentRoom()
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
.background(SybilTheme.background).environment(\.colorScheme, .dark))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer {
host.view.endEditing(true)
AgentRenderDiagnostics.enabled = false
window.isHidden = true; window.rootViewController = nil
room.stop()
}
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .seconds(2))
let scroll = try XCTUnwrap(findScroll(host.view))
let input = try XCTUnwrap(findInput(host.view))
AgentRenderDiagnostics.enabled = true
var reports: [[String: Any]] = []
func report(_ sampler: AgentFrameSampler, phase: String) {
var value = sampler.stop()
value["phase"] = phase
value["procedure"] = "mixed160-input-entrance-v1"
value["operations"] = AgentRenderDiagnostics.counts
value["contentHeight"] = scroll.contentSize.height
value["viewportHeight"] = scroll.bounds.height
reports.append(value)
}
AgentRenderDiagnostics.counts = [:]
let entrance = AgentFrameSampler(scroll: nil)
entrance.start()
for index in 161...172 {
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
// Use the production 100 ms publication batch and 240 ms entrance.
try await Task.sleep(for: .milliseconds(550))
assertAtBottom(scroll)
}
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
"Entrance frames must not repeatedly project the history")
report(entrance, phase: "message-entrance")
XCTAssertEqual(room.snapshot.room.messages.count, 172)
assertAtBottom(scroll)
capture(host, "input-entrance-latest")
let unfocusedHeight = scroll.bounds.height
AgentRenderDiagnostics.counts = [:]
let keyboard = AgentFrameSampler(scroll: nil)
keyboard.start()
for _ in 0..<6 {
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(600))
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
assertAtBottom(scroll)
XCTAssertTrue(input.resignFirstResponder())
try await Task.sleep(for: .milliseconds(600))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Keyboard focus and geometry must not project the transcript")
report(keyboard, phase: "keyboard-presentation")
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(700))
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
AgentRenderDiagnostics.counts = [:]
let typing = AgentFrameSampler(scroll: nil)
typing.start()
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
for character in text + text {
keyInput.insertText(String(character))
try await Task.sleep(for: .milliseconds(30))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Draft edits and line wrapping must not project the transcript")
assertAtBottom(scroll)
report(typing, phase: "composer-typing")
XCTAssertEqual(room.composer, text + text)
capture(host, "input-multiline-keyboard")
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
scroll.contentSize.height, accuracy: 25, file: file, line: line)
}
private func findInput(_ view: UIView) -> UIView? {
if view is UITextView || view is UITextField { return view }
return view.subviews.lazy.compactMap { self.findInput($0) }.first
}
private func capture(_ host: UIViewController, _ name: String) {
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
private static func message(_ index: Int) -> Message {
let own = index % 3 == 0
let content = own
? "Can you review step \(index)? Keep the scroll position and the **literal input** intact.\n\nInclude the next checks."
: "### Investigation \(index)\n\nThe worker checked the local transcript and found a repeatable result. This paragraph contains **emphasis**, `inline code`, and a [reference](https://example.com).\n\n- Preserve the reading position while new messages arrive.\n- Keep attachments, queued messages, and status updates visible.\n- Verify the result on the simulator before publishing.\n\n```swift\nlet item = messages[\(index)]\nrender(item)\n```\n\nThe next step is to compare the measurements and report any remaining limitations."
return Message(id: "perf-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 60)),
role: own ? .user : .assistant, content: content,
metadata: .object(["kind": .string("message"), "entryId": .string(String(index))]))
}
private func busyActivity(step: Int) -> AgentActivity {
AgentActivity(state: "thinking", isThinking: true, queued: 1 + step % 3,
backgroundWorkers: 1 + step % 2,
tools: [.init(id: "local-review", name: "review_transcript", status: "running",
output: "Checked \(step * 7) of 200 local entries; preparing batch \(step + 1).")])
}
private func findScroll(_ view: UIView) -> UIScrollView? {
if let scroll = view as? UIScrollView, scroll.contentSize.height > 10_000 { return scroll }
return view.subviews.lazy.compactMap { self.findScroll($0) }.first
}
}
@MainActor
private final class AgentFrameSampler: NSObject {
private weak var scroll: UIScrollView?
private var link: CADisplayLink?
private var previous: CFTimeInterval?
private var started: CFTimeInterval = 0
private var gaps: [Double] = []
private var budgets: [Double] = []
private var origin: CGFloat = 0
private var scrollDistance: CGFloat = 0
init(scroll: UIScrollView?) { self.scroll = scroll }
func start() {
origin = scroll?.contentOffset.y ?? 0
started = CACurrentMediaTime()
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60)
link.add(to: .main, forMode: .common)
self.link = link
}
@objc private func frame(_ link: CADisplayLink) {
let now = CACurrentMediaTime()
if let previous { gaps.append((now - previous) * 1000); budgets.append((link.targetTimestamp - link.timestamp) * 1000) }
previous = now
if let scroll {
// Constant-speed triangular sweep over 2400 points, one cycle / 8 s.
let phase = (now - started).truncatingRemainder(dividingBy: 8) / 4
let delta = CGFloat(phase <= 1 ? phase : 2 - phase) * 2400
let target = max(-scroll.adjustedContentInset.top, origin - delta)
scrollDistance += abs(target - scroll.contentOffset.y)
scroll.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
func stop() -> [String: Any] {
link?.invalidate(); link = nil
let sorted = gaps.sorted()
let budget = budgets.sorted()[budgets.count / 2]
func percentile(_ value: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * value))] }
return ["seconds": CACurrentMediaTime() - started, "callbacks": gaps.count,
"medianBudgetMS": budget, "p50MS": percentile(0.5), "p95MS": percentile(0.95),
"p99MS": percentile(0.99), "maxMS": sorted.last ?? 0,
"over25MS": gaps.filter { $0 > 25 }.count, "over50MS": gaps.filter { $0 > 50 }.count,
"missedBudgetIntervals": gaps.reduce(0) { $0 + max(0, Int(($1 / budget).rounded()) - 1) },
"scrollDistance": scrollDistance]
}
}