mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
292 lines
18 KiB
Swift
292 lines
18 KiB
Swift
import HapiClient
|
|
@testable import HapiUI
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
@testable import HapiProtocol
|
|
|
|
/// Render the real ChatModel → ChatTranscriptView → recycled UIKit rows, not a
|
|
/// standalone ToolGroupBlockView specimen. HTTP is faked; SSE targets closed loopback.
|
|
@MainActor
|
|
final class ToolTranscriptPresentationTests: XCTestCase {
|
|
private struct Harness: View {
|
|
let model: ChatModel
|
|
let session: HubSession
|
|
let theme: HapiTheme
|
|
let size: DynamicTypeSize
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ChatTranscriptView(model: model)
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
ChatComposerView(interactor: model.interactor)
|
|
}
|
|
.navigationTitle("HAPI · UI specimen")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolPresentations(model: model, session: session, owner: "chat", openFile: { _ in })
|
|
}
|
|
.hapiTypography()
|
|
.hapiTheme(theme)
|
|
.environment(\.dynamicTypeSize, size)
|
|
.environment(\.colorScheme, theme.isDark ? .dark : .light)
|
|
.environment(\.hapiMarkdownCache, model.markdownCache)
|
|
.preferredColorScheme(theme.isDark ? .dark : .light)
|
|
}
|
|
}
|
|
|
|
func testGroupBrowserNeverExpandsTranscriptAndDismissalPreservesAnchor() async throws {
|
|
let credentials = InMemoryCredentialStore()
|
|
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
|
|
try credentials.store(HubCredentials(hubUrl: "http://127.0.0.1:1", accessToken: "test", jwt: "e30.\(payload).test"))
|
|
let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/tool-transcript-\(UUID().uuidString)",
|
|
credentialStore: credentials, performer: ToolTranscriptHTTP()))
|
|
let model = ChatModel(session: hub, sessionId: "tool-transcript")
|
|
defer { model.stop(); hub.shutdown() }
|
|
model.start()
|
|
try await eventually { !model.blocks.isEmpty && !model.isSyncingTail }
|
|
let groups = model.blocks.compactMap { block -> ToolGroupBlock? in
|
|
guard case .toolGroup(let group) = block else { return nil }
|
|
return group
|
|
}
|
|
XCTAssertEqual(groups.map(\.tools.count), [42, 5])
|
|
let first = try XCTUnwrap(groups.first)
|
|
let last = try XCTUnwrap(groups.last)
|
|
XCTAssertEqual(ToolGroupPresentation(first).title, "42 tool calls")
|
|
XCTAssertEqual(ToolGroupPresentation(last).title, "5 tool calls")
|
|
let controller = await hub.windows.open(sessionId: "tool-transcript")
|
|
|
|
let cases: [(String, HapiTheme, DynamicTypeSize, CGFloat)] = [
|
|
("light-transcript", .light, .large, 390),
|
|
("dark-transcript", .dark, .large, 390),
|
|
("small-transcript", .light, .large, 320),
|
|
("ax-transcript", .light, .accessibility3, 390),
|
|
]
|
|
for (name, theme, size, width) in cases {
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let window = UIWindow(windowScene: scene)
|
|
window.frame = CGRect(x: 0, y: 0, width: width, height: 844)
|
|
let host = UIHostingController(rootView: Harness(model: model, session: hub, theme: theme, size: size))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
host.view.layoutIfNeeded()
|
|
let collection = try XCTUnwrap(findCollection(host.view))
|
|
try await eventually { collection.numberOfItems(inSection: 0) == 6 }
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
for index in [2, 4] {
|
|
let summary = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0)))
|
|
XCTAssertGreaterThanOrEqual(summary.frame.height, 44)
|
|
if size == .large { XCTAssertLessThanOrEqual(summary.frame.height, 100) }
|
|
}
|
|
try capture(window, name: name)
|
|
let cell = try XCTUnwrap(collection.visibleCells
|
|
.filter { $0.accessibilityIdentifier != "chat-row-chat-history-control" }
|
|
.sorted { $0.frame.minY < $1.frame.minY }.first)
|
|
let anchorID = cell.accessibilityIdentifier
|
|
let anchorY = cell.frame.minY - collection.contentOffset.y
|
|
XCTAssertTrue(model.inspectToolGroup(first.id, owner: "chat"))
|
|
try await eventually { host.presentedViewController != nil && !model.followsTail }
|
|
try await Task.sleep(for: .milliseconds(600))
|
|
XCTAssertTrue(model.isInspectingContent)
|
|
XCTAssertTrue(model.showsJumpToLatest, "Viewport proximity must not hide an inspection's resume action")
|
|
XCTAssertNil(model.toolInspection.selection, "Group root must pause following without a selected tool")
|
|
XCTAssertEqual(collection.numberOfItems(inSection: 0), 6)
|
|
let presented = try XCTUnwrap(host.presentedViewController)
|
|
let transcript = try XCTUnwrap(findTranscriptController(host))
|
|
#if DEBUG
|
|
let configurations = transcript.cellConfigurationCount
|
|
#endif
|
|
let tool = try XCTUnwrap(first.tools.last)
|
|
for update in 0..<3 {
|
|
let seq = (await controller.state.newestSeq ?? 0) + 1
|
|
let result = "\(name) streamed result \(update)"
|
|
let message = DecryptedMessage(id: "live-\(seq)", seq: seq,
|
|
content: ["role": "agent", "content": ["type": "codex", "data": [
|
|
"type": "tool-call-result", "callId": .string(tool.tool.id),
|
|
"output": .string(result), "is_error": false,
|
|
]]], createdAt: seq * 1000, invokedAt: seq * 1000)
|
|
await controller.onMessageEvent(.messageReceived(namespace: nil, sessionId: "tool-transcript", message: message))
|
|
try await eventually { model.toolInspection.tools[tool.id]?.tool.result == .string(result) }
|
|
XCTAssertTrue(host.presentedViewController === presented)
|
|
XCTAssertEqual(collection.numberOfItems(inSection: 0), 6)
|
|
}
|
|
#if DEBUG
|
|
XCTAssertEqual(transcript.cellConfigurationCount, configurations,
|
|
"Member output must not reconfigure unchanged transcript summaries")
|
|
#endif
|
|
if size.isAccessibilitySize {
|
|
let browser = try XCTUnwrap(findCollection(presented.view))
|
|
let lastRow = try XCTUnwrap(browser.layoutAttributesForItem(at: IndexPath(item: 41, section: 0)))
|
|
XCTAssertGreaterThan(lastRow.frame.height, 60, "The sheet must inherit the conversation's Dynamic Type")
|
|
}
|
|
try capture(window, name: "\(name)-browser")
|
|
model.toolInspection.dismiss(owner: "chat")
|
|
try await eventually { host.presentedViewController == nil && !model.isInspectingContent }
|
|
let restored = try XCTUnwrap(collection.visibleCells.first { $0.accessibilityIdentifier == anchorID })
|
|
XCTAssertEqual(restored.frame.minY - collection.contentOffset.y, anchorY, accuracy: 1)
|
|
XCTAssertFalse(model.followsTail)
|
|
XCTAssertTrue(model.showsJumpToLatest, "The real transcript must retain its resume action after dismissal")
|
|
XCTAssertEqual(collection.numberOfItems(inSection: 0), 6)
|
|
}
|
|
}
|
|
|
|
func testPlanPublicationUpdatesRecyclingAndInspectorPreserveTheDocumentAndAnchor() async throws {
|
|
let plan = "# Initial proposal\n\nRead the document without opening the inspector."
|
|
let credentials = InMemoryCredentialStore()
|
|
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
|
|
try credentials.store(HubCredentials(hubUrl: "http://127.0.0.1:1", accessToken: "test", jwt: "e30.\(payload).test"))
|
|
let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/plan-transcript-\(UUID().uuidString)",
|
|
credentialStore: credentials, performer: ToolTranscriptHTTP(proposedPlan: plan)))
|
|
let model = ChatModel(session: hub, sessionId: "tool-transcript")
|
|
defer { model.stop(); hub.shutdown() }
|
|
model.start()
|
|
try await eventually { !model.blocks.isEmpty && !model.isSyncingTail }
|
|
XCTAssertNotNil(model.markdownCache.cached(plan), "Prewarm plan Markdown before publishing rows")
|
|
let index = try XCTUnwrap(model.blocks.firstIndex { $0.stableId == "proposal" }) + 1
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let window = UIWindow(windowScene: scene)
|
|
window.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
let host = UIHostingController(rootView: Harness(model: model, session: hub, theme: .light, size: .large))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
host.view.layoutIfNeeded()
|
|
try await eventually { findCollection(host.view) != nil }
|
|
let collection = try XCTUnwrap(findCollection(host.view))
|
|
try await eventually { collection.numberOfItems(inSection: 0) == model.blocks.count + 1 }
|
|
collection.delegate?.scrollViewWillBeginDragging?(collection)
|
|
collection.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
try await eventually { collection.cellForItem(at: IndexPath(item: index, section: 0)) != nil }
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
let cell = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: index, section: 0)))
|
|
let initialHeight = cell.frame.height
|
|
XCTAssertGreaterThan(initialHeight, 100, "A plan must not remain a summary row")
|
|
let anchorY = cell.frame.minY - collection.contentOffset.y
|
|
let updated = "# Revised proposal\n\n" + String(repeating: "More plan detail. ", count: 150)
|
|
let controller = await hub.windows.open(sessionId: "tool-transcript")
|
|
let seq = (await controller.state.newestSeq ?? 0) + 1
|
|
let message = DecryptedMessage(id: "plan-update", seq: seq,
|
|
content: ["role": "agent", "content": ["type": "codex", "data": [
|
|
"type": "tool-call", "callId": "proposal", "name": "ExitPlanMode",
|
|
"input": ["plan": .string(updated)],
|
|
]]], createdAt: seq * 1000, invokedAt: seq * 1000)
|
|
await controller.onMessageEvent(.messageReceived(namespace: nil, sessionId: "tool-transcript", message: message))
|
|
try await eventually { model.toolInspection.tools["proposal"]?.tool.input?[chatKey: "plan"]?.chatString == updated }
|
|
XCTAssertNotNil(model.markdownCache.cached(updated))
|
|
try await eventually { (collection.cellForItem(at: IndexPath(item: index, section: 0))?.frame.height ?? 0) > initialHeight + 100 }
|
|
XCTAssertEqual(cell.frame.minY - collection.contentOffset.y, anchorY, accuracy: 1)
|
|
let count = collection.numberOfItems(inSection: 0)
|
|
collection.scrollToItem(at: IndexPath(item: count - 1, section: 0), at: .bottom, animated: false)
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
XCTAssertNil(collection.cellForItem(at: IndexPath(item: index, section: 0)))
|
|
collection.delegate?.scrollViewWillBeginDragging?(collection)
|
|
collection.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
let returned = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: index, section: 0)))
|
|
XCTAssertGreaterThan(returned.frame.height, initialHeight + 100)
|
|
let restoredY = returned.frame.minY - collection.contentOffset.y
|
|
model.beginContentInspection()
|
|
model.retainSurface("inspector:chat")
|
|
model.toolInspection.open(try XCTUnwrap(model.toolInspection.tools["proposal"]), owner: "chat")
|
|
try await eventually { host.presentedViewController != nil }
|
|
XCTAssertEqual(planProposalMarkdown(try XCTUnwrap(model.toolInspection.selection).block.tool), updated)
|
|
model.toolInspection.dismiss(owner: "chat")
|
|
try await eventually { host.presentedViewController == nil && !model.isInspectingContent }
|
|
XCTAssertEqual(returned.frame.minY - collection.contentOffset.y, restoredY, accuracy: 1)
|
|
XCTAssertEqual(collection.numberOfItems(inSection: 0), count)
|
|
XCTAssertFalse(model.followsTail)
|
|
}
|
|
|
|
private func eventually(_ condition: () -> Bool) async throws {
|
|
for _ in 0..<150 {
|
|
if condition() { return }
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
XCTFail("Transcript did not settle")
|
|
}
|
|
|
|
private func findCollection(_ view: UIView) -> UICollectionView? {
|
|
if let collection = view as? UICollectionView { return collection }
|
|
return view.subviews.lazy.compactMap(findCollection).first
|
|
}
|
|
|
|
private func findTranscriptController(_ controller: UIViewController) -> TranscriptCollectionController<TranscriptRow>? {
|
|
if let transcript = controller as? TranscriptCollectionController<TranscriptRow> { return transcript }
|
|
return controller.children.lazy.compactMap(findTranscriptController).first
|
|
}
|
|
|
|
private func capture(_ window: UIWindow, name: String) throws {
|
|
guard let path = ProcessInfo.processInfo.environment["HAPI_TOOL_CAPTURE"] else { return }
|
|
let directory = URL(fileURLWithPath: path, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
|
|
let image = renderer.image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }
|
|
try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent("\(name).png"))
|
|
}
|
|
}
|
|
|
|
private struct ToolTranscriptHTTP: HTTPPerforming {
|
|
var proposedPlan: String? = nil
|
|
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
let url = request.url!
|
|
guard url.lastPathComponent == "messages" else {
|
|
return (Data(#"{"error":"unused test endpoint"}"#.utf8),
|
|
HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!)
|
|
}
|
|
let after = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.contains { $0.name == "afterAt" } == true
|
|
let rows = messages()
|
|
let response = MessagesResponse(messages: after ? [] : rows, page: MessagesPage(
|
|
direction: after ? .after : .latest, limit: 200, epoch: 1, reset: false,
|
|
nextBeforeSeq: after ? nil : rows.first?.seq, nextBeforeAt: after ? nil : rows.first?.createdAt,
|
|
nextAfterSeq: after ? rows.last?.seq : nil, nextAfterAt: after ? rows.last?.createdAt : nil,
|
|
snapshotHeadSeq: rows.last?.seq, snapshotHeadAt: rows.last?.createdAt, hasMore: false
|
|
))
|
|
return (try JSONEncoder().encode(response), HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
|
}
|
|
|
|
private func messages() -> [DecryptedMessage] {
|
|
var rows: [DecryptedMessage] = []
|
|
func append(_ data: JSONValue) {
|
|
let seq = rows.count + 1
|
|
rows.append(DecryptedMessage(id: "row-\(seq)", seq: seq,
|
|
content: ["role": "agent", "content": ["type": "codex", "data": data]],
|
|
createdAt: seq * 1000, invokedAt: seq * 1000))
|
|
}
|
|
func tool(_ name: String, input: JSONValue, error: Bool = false) {
|
|
let id = "tool-\(rows.count)"
|
|
append(["type": "tool-call", "callId": .string(id), "name": .string(name), "input": input])
|
|
append(["type": "tool-call-result", "callId": .string(id), "output": "Done", "is_error": .bool(error)])
|
|
}
|
|
append(["type": "message", "message": "工具详情独立阅读;关闭后保留聊天位置。"])
|
|
for index in 0..<42 {
|
|
// Exercise the shared Codex wire shape, not just legacy Bash calls:
|
|
// unknown actions must remain grouped in the real transcript/inspector.
|
|
if index < 17 {
|
|
tool("CodexBash", input: [
|
|
"command": "git diff --stat", "command_source": "unifiedExecStartup",
|
|
"command_actions": [["type": "unknown", "command": "git diff --stat"]],
|
|
], error: index == 0)
|
|
}
|
|
else if index < 23 { tool("Edit", input: ["file_path": "/workspace/ChatView.swift"]) }
|
|
else { tool("exec", input: ["code": "await tools.exec_command({cmd: 'swift test'})"]) }
|
|
}
|
|
append(["type": "message", "message": "实现与测试已完成。正在核对浅色/深色 UI,并清理测试生成文件。"])
|
|
tool("view_image", input: ["path": "/tmp/hapi-tool-inspector-review-20260911-final/light-summary.png"])
|
|
tool("view_image", input: ["path": "/tmp/hapi-tool-inspector-review-20260911-final/dark-detail.png"])
|
|
tool("Bash", input: ["command": "rm ios/Packages/HapiKit/Package.resolved && git diff --check && git status --short"])
|
|
tool("exec", input: ["code": "await tools.mcp__hapi__display_image({path: '/tmp/light-summary.png'})"])
|
|
tool("mcp__hapi__display_image", input: ["path": "/tmp/light-summary.png"])
|
|
if let proposedPlan {
|
|
append(["type": "tool-call", "name": "ExitPlanMode", "callId": "proposal", "input": ["plan": .string(proposedPlan)]])
|
|
append(["type": "tool-call-result", "callId": "proposal", "output": .null])
|
|
for index in 0..<20 {
|
|
append(["type": "message", "id": .string("after-plan-\(index)"), "message": .string("Later message \(index)\n\nKeeps the plan away from the live tail for recycling tests.")])
|
|
}
|
|
}
|
|
append(["type": "message", "message": "检查完成,可以继续对话。"])
|
|
return rows
|
|
}
|
|
}
|