mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
- Use the split navigation on foldable iPhones once UIHingeInteraction (iOS 27.1+) reports a hinge: unfolded shows sidebar + chat, folded collapses to the phone stack without recreating the chat. - Side-by-side sidebar: drop the truncated inline "Sessions" title and move the filter beside the hub menu (also fixes iPad). - Folded outer screen: drop the home title so the empty bar disappears, use the leading-aligned system title/subtitle in chat, minimize the chat title on scroll, and let the composer sit 16pt closer to the home indicator while the text field is unfocused. - Qualify Markdown.Document, which is ambiguous with the iOS 27.1 SDK. - test-transcript.sh picks the newest runtime that supports the device type (iOS 27.1 only ships iPhone Duo); split tests run on iPhone Duo.
452 lines
24 KiB
Swift
452 lines
24 KiB
Swift
import HapiClient
|
||
import HapiProtocol
|
||
import Observation
|
||
import SwiftUI
|
||
import UIKit
|
||
import XCTest
|
||
@testable import Hapi
|
||
@testable import HapiUI
|
||
|
||
/// Runs on a real iPad or iPhone Duo simulator, using the production split
|
||
/// shell and list.
|
||
/// No pairing, saved credentials or live hub: HTTP is faked, SSE uses closed
|
||
/// loopback. Synthetic detail pages exercise native nested navigation; the
|
||
/// chat test renders the real ChatView/composer/anchored transcript.
|
||
@MainActor
|
||
final class SessionSplitPresentationTests: XCTestCase {
|
||
private enum Page: Hashable { case files }
|
||
|
||
@Observable fileprivate final class Probe {
|
||
var instances: [String: UUID] = [:]
|
||
/// The detail column's size class; ChatView keys folded-Duo chrome on it.
|
||
var detailSizeClass: UserInterfaceSizeClass?
|
||
}
|
||
|
||
/// SwiftUI accessibility elements need not be UIViews. Use native probes
|
||
/// for app-hosted presence assertions rather than treating AX IDs as views.
|
||
private struct Presence: UIViewRepresentable {
|
||
let id: String
|
||
func makeUIView(context: Context) -> UIView {
|
||
let view = UIView()
|
||
view.accessibilityIdentifier = id
|
||
return view
|
||
}
|
||
func updateUIView(_ view: UIView, context: Context) { view.accessibilityIdentifier = id }
|
||
}
|
||
|
||
private struct DetailProbe: View {
|
||
let sessionId: String
|
||
let probe: Probe
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
@State private var instance = UUID()
|
||
@State private var draft = ""
|
||
|
||
var body: some View {
|
||
Form {
|
||
Text(verbatim: sessionId).accessibilityIdentifier("detail-\(sessionId)")
|
||
TextField("Draft", text: $draft)
|
||
NavigationLink("Files", value: Page.files)
|
||
}
|
||
.navigationTitle(Text(verbatim: sessionId))
|
||
.overlay { Presence(id: "detail-\(sessionId)").frame(width: 1, height: 1).allowsHitTesting(false) }
|
||
.navigationDestination(for: Page.self) { _ in
|
||
Text("Files")
|
||
.overlay { Presence(id: "detail-files").frame(width: 1, height: 1).allowsHitTesting(false) }
|
||
}
|
||
.onAppear { probe.instances[sessionId] = instance }
|
||
.onChange(of: horizontalSizeClass, initial: true) { _, sizeClass in
|
||
probe.detailSizeClass = sizeClass
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct Harness: View {
|
||
let navigation: SessionNavigationState
|
||
let list: SessionListModel
|
||
let probe: Probe
|
||
|
||
var body: some View {
|
||
SessionSplitView(navigation: navigation, onNewSession: { navigation.open("created") }) {
|
||
SessionListView(model: list, selection: Binding(
|
||
get: { navigation.selectedSessionId },
|
||
set: { if let id = $0 { navigation.open(id) } }
|
||
), onOpenSession: navigation.open)
|
||
.navigationTitle("Sessions")
|
||
.overlay { Presence(id: "home.sessions").frame(width: 1, height: 1).allowsHitTesting(false) }
|
||
} detail: { id in
|
||
DetailProbe(sessionId: id, probe: probe)
|
||
}
|
||
.hapiTypography()
|
||
.hapiTheme(.light)
|
||
}
|
||
}
|
||
|
||
func testWideSelectionSurvivesFilteringAndNestedNavigationAcrossCollapse() async throws {
|
||
try await requireSplitDevice()
|
||
let navigation = SessionNavigationState()
|
||
let probe = Probe()
|
||
let sessions = HomeFilterTestSessions([
|
||
HomeFilterTestData.summary("a", machine: "mac"),
|
||
HomeFilterTestData.summary("b", machine: "debian"),
|
||
])
|
||
let list = HomeFilterTestData.model(sessions: sessions)
|
||
let (window, host) = try makeWindow(Harness(navigation: navigation, list: list, probe: probe),
|
||
width: wide.width, height: wide.height)
|
||
defer { window.isHidden = true }
|
||
try await settle { list.hasRefreshedOnce }
|
||
XCTAssertTrue(probe.instances.isEmpty, "An empty detail must not start/mark a chat read")
|
||
let split = try XCTUnwrap(findController(UISplitViewController.self, in: host))
|
||
XCTAssertFalse(split.isCollapsed)
|
||
|
||
navigation.open("a")
|
||
try await settle { probe.instances["a"] != nil }
|
||
let identity = try XCTUnwrap(probe.instances["a"])
|
||
XCTAssertEqual(probe.detailSizeClass, .regular, "A side-by-side detail must not get folded-screen chrome")
|
||
let primaryView = try XCTUnwrap(split.viewController(for: .primary)?.view)
|
||
let sidebar = try XCTUnwrap(self.findCollection(in: primaryView))
|
||
let primaryFrame = primaryView.convert(primaryView.bounds, to: window)
|
||
let sidebarFrame = sidebar.convert(sidebar.bounds, to: window)
|
||
// NavigationSplitView treats the requested column width as a preferred
|
||
// range. The balanced iPad style may expand it beyond the 360pt request
|
||
// on a wide window, so assert the actual responsive invariant instead
|
||
// of coupling the test to one UIKit measurement or OS release.
|
||
XCTAssertEqual(sidebarFrame.width, primaryFrame.width, accuracy: 1)
|
||
XCTAssertGreaterThanOrEqual(primaryFrame.width, 280)
|
||
XCTAssertLessThanOrEqual(primaryFrame.width, window.bounds.width / 2)
|
||
|
||
list.selectMachine("debian")
|
||
try await settle { list.rows.map(\.id) == ["b"] }
|
||
XCTAssertEqual(navigation.selectedSessionId, "a")
|
||
navigation.detailPath.append(Page.files)
|
||
try await settle { self.findView("detail-files", in: window) != nil }
|
||
resize(window, host: host, width: 390, height: 844, compact: true)
|
||
try await settle { split.isCollapsed }
|
||
XCTAssertEqual(probe.detailSizeClass, .compact)
|
||
XCTAssertEqual(navigation.selectedSessionId, "a")
|
||
XCTAssertEqual(navigation.detailPath.count, 1)
|
||
XCTAssertNotNil(findView("detail-files", in: window))
|
||
|
||
resize(window, host: host, width: wide.width, height: wide.height, compact: false)
|
||
try await settle { !split.isCollapsed }
|
||
navigation.detailPath.removeLast()
|
||
try await settle { self.findView("detail-a", in: window) != nil }
|
||
XCTAssertEqual(probe.instances["a"], identity)
|
||
XCTAssertEqual(list.activeMachineFilter, "debian")
|
||
// Neither filtering nor geometry should refresh the sidebar again.
|
||
XCTAssertEqual(sessions.refreshCount, 1)
|
||
}
|
||
|
||
func testCompactProgrammaticOpenAndBackAfterAFilePush() async throws {
|
||
try await requireSplitDevice()
|
||
let navigation = SessionNavigationState()
|
||
let probe = Probe()
|
||
let list = HomeFilterTestData.model(sessions: HomeFilterTestSessions([
|
||
HomeFilterTestData.summary("a", machine: nil), HomeFilterTestData.summary("b", machine: nil),
|
||
]))
|
||
let (window, host) = try makeWindow(Harness(navigation: navigation, list: list, probe: probe),
|
||
width: 390, height: 844, compact: true)
|
||
defer { window.isHidden = true }
|
||
let split = try XCTUnwrap(findController(UISplitViewController.self, in: host))
|
||
try await settle { split.isCollapsed }
|
||
// A notification/new session need not have a matching sidebar row.
|
||
navigation.open("not-in-list")
|
||
try await settle { self.findView("detail-not-in-list", in: window) != nil }
|
||
navigation.detailPath.append(Page.files)
|
||
try await settle { self.findView("detail-files", in: window) != nil }
|
||
navigation.detailPath.removeLast()
|
||
try await settle { self.findView("detail-not-in-list", in: window) != nil }
|
||
try nativeBack(in: host)
|
||
try await settle { self.findView("home.sessions", in: window) != nil }
|
||
navigation.open("b")
|
||
try await settle { self.findView("detail-b", in: window) != nil }
|
||
XCTAssertEqual(navigation.selectedSessionId, "b")
|
||
XCTAssertTrue(navigation.detailPath.isEmpty)
|
||
navigation.remove("b")
|
||
try await settle { self.findView("home.sessions", in: window) != nil }
|
||
XCTAssertNil(navigation.selectedSessionId)
|
||
}
|
||
|
||
private struct ChatHarness: View {
|
||
let navigation: SessionNavigationState
|
||
let list: SessionListModel
|
||
let hub: HubSession
|
||
let model: ChatModel
|
||
|
||
var body: some View {
|
||
SessionSplitView(navigation: navigation, onNewSession: {}) {
|
||
SessionListView(model: list, selection: Binding(
|
||
get: { navigation.selectedSessionId },
|
||
set: { if let id = $0 { navigation.open(id) } }
|
||
), onOpenSession: navigation.open)
|
||
.navigationTitle("Sessions")
|
||
} detail: { _ in
|
||
ChatView(session: hub, model: model)
|
||
}
|
||
.hapiTypography()
|
||
.hapiTheme(.light)
|
||
}
|
||
}
|
||
|
||
func testRootRoutesNotificationsRemovalsAndHubSwitches() async throws {
|
||
try await requireSplitDevice()
|
||
let suite = "ipad-root-\(UUID())"
|
||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||
defer { defaults.removePersistentDomain(forName: suite) }
|
||
let registry = HubRegistry(defaults: defaults)
|
||
let credentials = InMemoryCredentialStore()
|
||
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
|
||
for url in ["http://127.0.0.1:1", "http://127.0.0.1:2"] {
|
||
registry.register(url)
|
||
try credentials.store(HubCredentials(hubUrl: url, accessToken: "test", jwt: "e30.\(payload).test"))
|
||
}
|
||
let model = AppModel(registry: registry, credentialStore: credentials, performer: SplitTestHTTP())
|
||
let firstHub = try XCTUnwrap(model.session)
|
||
let (window, host) = try makeWindow(RootView().environment(model), width: current.size.width,
|
||
height: current.size.height, compact: current.compact)
|
||
defer { window.isHidden = true; model.session?.shutdown(); firstHub.shutdown() }
|
||
try await settle { firstHub.sessionStore.sessions.count == 2 }
|
||
XCTAssertNil(firstHub.openChatSessionId)
|
||
XCTAssertNotNil(findController(UISplitViewController.self, in: host), "Home must use the split shell")
|
||
XCTAssertNil(findController(TranscriptCollectionController<TranscriptRow>.self, in: host))
|
||
model.pendingOpenSessionId = "a"
|
||
try await settle { firstHub.openChatSessionId == "a" && self.findController(TranscriptCollectionController<TranscriptRow>.self, in: host) != nil }
|
||
XCTAssertNil(model.pendingOpenSessionId)
|
||
try capture(window, name: "ipad-home")
|
||
host.overrideUserInterfaceStyle = .dark
|
||
try await settle { host.traitCollection.userInterfaceStyle == .dark }
|
||
try capture(window, name: "ipad-home-dark")
|
||
|
||
firstHub.sessionStore.applySessionEvent(.sessionRemoved(namespace: nil, sessionId: "b"))
|
||
try await Task.sleep(for: .milliseconds(250))
|
||
XCTAssertEqual(firstHub.openChatSessionId, "a")
|
||
firstHub.sessionStore.applySessionEvent(.sessionRemoved(namespace: nil, sessionId: "a"))
|
||
try await settle { firstHub.openChatSessionId == nil }
|
||
XCTAssertNil(findController(TranscriptCollectionController<TranscriptRow>.self, in: host))
|
||
|
||
model.switchHub(to: "http://127.0.0.1:2")
|
||
let secondHub = try XCTUnwrap(model.session)
|
||
try await settle { secondHub.sessionStore.sessions.count == 2 }
|
||
XCTAssertNil(secondHub.openChatSessionId)
|
||
model.pendingOpenSessionId = "b"
|
||
try await settle { secondHub.openChatSessionId == "b" }
|
||
XCTAssertNil(firstHub.openChatSessionId)
|
||
}
|
||
|
||
func testRealChatResizePreservesTranscriptDraftAttachmentsAndSingleSurface() async throws {
|
||
try await requireSplitDevice()
|
||
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 http = SplitTestHTTP()
|
||
let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/ipad-chat-\(UUID())",
|
||
credentialStore: credentials, performer: http))
|
||
let model = ChatModel(session: hub, sessionId: "a")
|
||
let navigation = SessionNavigationState()
|
||
navigation.open("a")
|
||
let sessions = HomeFilterTestSessions([
|
||
HomeFilterTestData.summary("a", machine: "mac"), HomeFilterTestData.summary("b", machine: "debian"),
|
||
])
|
||
let list = HomeFilterTestData.model(sessions: sessions)
|
||
let (window, host) = try makeWindow(ChatHarness(navigation: navigation, list: list, hub: hub, model: model),
|
||
width: wide.width, height: wide.height)
|
||
defer { window.isHidden = true; model.stop(); hub.shutdown() }
|
||
try await settle { !model.isInitialLoading && !model.blocks.isEmpty && !model.isSyncingTail }
|
||
let transcript = try XCTUnwrap(findController(TranscriptCollectionController<TranscriptRow>.self, in: host))
|
||
let collection = try XCTUnwrap(findCollection(in: transcript.view))
|
||
model.interactor.setComposerText("检查 iPad 分栏与窗口缩放;这段草稿不能丢失。")
|
||
model.interactor.attachments.add(PreparedAttachment(filename: "layout.txt", mimeType: "text/plain", bytes: Data("iPad".utf8)))
|
||
try await settle { model.interactor.attachments.items.first?.status == .ready }
|
||
let attachment = try XCTUnwrap(model.interactor.attachments.items.first)
|
||
let requests = await http.messageRequests
|
||
try capture(window, name: "ipad-wide")
|
||
|
||
collection.delegate?.scrollViewWillBeginDragging?(collection)
|
||
collection.scrollToItem(at: IndexPath(item: 20, section: 0), at: .top, animated: false)
|
||
try await settle { !model.followsTail }
|
||
let cell = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 20, section: 0)))
|
||
let anchorID = cell.accessibilityIdentifier
|
||
let anchorY = cell.frame.minY - collection.contentOffset.y
|
||
|
||
for (width, height, compact) in [(820.0, 1180.0, false), (600, 900, false), (390, 844, true), (1194, 834, false)] {
|
||
resize(window, host: host, width: width, height: height, compact: compact)
|
||
try await settle { self.findController(TranscriptCollectionController<TranscriptRow>.self, in: host) != nil }
|
||
XCTAssertTrue(findController(TranscriptCollectionController<TranscriptRow>.self, in: host) === transcript)
|
||
let restored = try XCTUnwrap(collection.visibleCells.first { $0.accessibilityIdentifier == anchorID })
|
||
XCTAssertEqual(restored.frame.minY - collection.contentOffset.y, anchorY, accuracy: 2)
|
||
XCTAssertEqual(model.interactor.composerText, "检查 iPad 分栏与窗口缩放;这段草稿不能丢失。")
|
||
XCTAssertEqual(model.interactor.attachments.items.first?.id, attachment.id)
|
||
XCTAssertEqual(model.visibleSurfaces, ["chat"])
|
||
XCTAssertEqual(hub.openChatSessionId, "a")
|
||
let rowWidth = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: 20, section: 0))).size.width
|
||
XCTAssertLessThanOrEqual(rowWidth, 720)
|
||
if compact { try capture(window, name: "ipad-compact") }
|
||
}
|
||
navigation.columnVisibility = .detailOnly
|
||
try await settle { collection.bounds.width > 1000 }
|
||
navigation.columnVisibility = .all
|
||
try await settle { collection.bounds.width < 1000 }
|
||
XCTAssertTrue(findController(TranscriptCollectionController<TranscriptRow>.self, in: host) === transcript)
|
||
let finalRequests = await http.messageRequests
|
||
XCTAssertEqual(finalRequests, requests, "Geometry must not cold-start the chat's message window")
|
||
XCTAssertEqual(sessions.refreshCount, 1)
|
||
|
||
// A genuinely hidden chat must stop claiming visibility/marking read,
|
||
// unlike a detail that only moved during a window resize.
|
||
resize(window, host: host, width: 390, height: 844, compact: true)
|
||
try await settle { self.findController(UISplitViewController.self, in: host)?.isCollapsed == true }
|
||
try nativeBack(in: host)
|
||
try await settle { model.visibleSurfaces.isEmpty && hub.openChatSessionId == nil }
|
||
navigation.open("a")
|
||
try await settle { model.visibleSurfaces == ["chat"] && hub.openChatSessionId == "a" }
|
||
XCTAssertEqual(model.interactor.attachments.items.first?.id, attachment.id)
|
||
}
|
||
|
||
/// Landscape iPad Air, or the unfolded iPhone Duo inner screen. The test
|
||
/// simulator may boot folded (466×678pt, compact), so this is not read
|
||
/// from the current screen.
|
||
private var wide: CGSize {
|
||
UIDevice.current.userInterfaceIdiom == .pad
|
||
? CGSize(width: 1194, height: 834)
|
||
: CGSize(width: 951, height: 669)
|
||
}
|
||
|
||
/// iPad keeps its fixed wide window; a foldable uses its current screen and
|
||
/// size class so captures match the folded or unfolded device.
|
||
private var current: (size: CGSize, compact: Bool) {
|
||
guard UIDevice.current.userInterfaceIdiom != .pad,
|
||
let scene = hostScene
|
||
else { return (wide, false) }
|
||
return (scene.screen.bounds.size, scene.traitCollection.horizontalSizeClass == .compact)
|
||
}
|
||
|
||
/// The visible scene; an unfolded Duo also connects one for its outer display.
|
||
private var hostScene: UIWindowScene? {
|
||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||
return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
||
}
|
||
|
||
/// iPad, or a foldable iPhone once its hinge has been reported.
|
||
private func requireSplitDevice() async throws {
|
||
if UIDevice.current.userInterfaceIdiom == .pad || FoldableDevice.shared.hasHinge { return }
|
||
let (window, _) = try makeWindow(HingeProbe(), width: 390, height: 844)
|
||
defer { window.isHidden = true }
|
||
for _ in 0..<50 where !FoldableDevice.shared.hasHinge {
|
||
try await Task.sleep(for: .milliseconds(20))
|
||
}
|
||
try XCTSkipIf(!FoldableDevice.shared.hasHinge,
|
||
"Run with HAPI_TEST_DEVICE_TYPE=iPad-Air-11-inch-M2 or iPhone-Duo")
|
||
}
|
||
|
||
private func makeWindow<V: View>(_ view: V, width: CGFloat, height: CGFloat, compact: Bool = false) throws -> (UIWindow, UIHostingController<V>) {
|
||
let scene = try XCTUnwrap(hostScene)
|
||
let window = UIWindow(windowScene: scene)
|
||
let host = UIHostingController(rootView: view)
|
||
window.rootViewController = host
|
||
resize(window, host: host, width: width, height: height, compact: compact)
|
||
window.makeKeyAndVisible()
|
||
host.view.layoutIfNeeded()
|
||
return (window, host)
|
||
}
|
||
|
||
private func resize(_ window: UIWindow, host: UIViewController, width: CGFloat, height: CGFloat, compact: Bool) {
|
||
host.traitOverrides.horizontalSizeClass = compact ? .compact : .regular
|
||
window.frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||
window.layoutIfNeeded()
|
||
host.view.layoutIfNeeded()
|
||
}
|
||
|
||
private func settle(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async throws {
|
||
for _ in 0..<150 {
|
||
try await Task.sleep(for: .milliseconds(20))
|
||
if condition() {
|
||
try await Task.sleep(for: .milliseconds(350))
|
||
return
|
||
}
|
||
}
|
||
XCTFail("iPad navigation/layout did not settle", file: file, line: line)
|
||
}
|
||
|
||
private func findView(_ id: String, in view: UIView) -> UIView? {
|
||
guard !view.isHidden else { return nil }
|
||
if view.accessibilityIdentifier == id { return view }
|
||
return view.subviews.lazy.compactMap { self.findView(id, in: $0) }.first
|
||
}
|
||
|
||
private func findCollection(in view: UIView) -> UICollectionView? {
|
||
if let result = view as? UICollectionView { return result }
|
||
return view.subviews.lazy.compactMap { self.findCollection(in: $0) }.first
|
||
}
|
||
|
||
private func findController<T: UIViewController>(_ type: T.Type, in controller: UIViewController) -> T? {
|
||
if let result = controller as? T { return result }
|
||
return controller.children.lazy.compactMap { self.findController(type, in: $0) }.first
|
||
}
|
||
|
||
private func nativeBack(in controller: UIViewController) throws {
|
||
func navigations(_ parent: UIViewController) -> [UINavigationController] {
|
||
((parent as? UINavigationController).map { [$0] } ?? [])
|
||
+ parent.children.flatMap(navigations)
|
||
}
|
||
let navigation = try XCTUnwrap(navigations(controller).last {
|
||
$0.view.window != nil && $0.viewControllers.count > 1
|
||
})
|
||
XCTAssertNotNil(navigation.popViewController(animated: true))
|
||
}
|
||
|
||
private func capture(_ window: UIWindow, name: String) throws {
|
||
guard let path = ProcessInfo.processInfo.environment["HAPI_IPAD_CAPTURE"] else { return }
|
||
let directory = URL(fileURLWithPath: path, isDirectory: true)
|
||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
||
}
|
||
try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent("\(name).png"))
|
||
}
|
||
}
|
||
|
||
private actor SplitTestHTTP: HTTPPerforming {
|
||
private(set) var messageRequests = 0
|
||
|
||
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||
let url = try XCTUnwrap(request.url)
|
||
var status = 200
|
||
let data: Data
|
||
switch url.lastPathComponent {
|
||
case "messages":
|
||
messageRequests += 1
|
||
let rows = (1...80).map { index in
|
||
DecryptedMessage(id: "message-\(index)", seq: index,
|
||
content: ["role": "agent", "content": ["type": "codex", "data": [
|
||
"type": "message", "message": .string("布局检查 \(index) · iPad workspace\n\n" + String(repeating: "Keep the conversation readable while resizing the window. ", count: 3))
|
||
]]], createdAt: index * 1000, invokedAt: index * 1000)
|
||
}
|
||
data = try JSONEncoder().encode(MessagesResponse(messages: rows, page: MessagesPage(
|
||
direction: .latest, limit: 200, epoch: 1, reset: false,
|
||
nextBeforeSeq: 1, nextBeforeAt: 1000, snapshotHeadSeq: 80, snapshotHeadAt: 80000, hasMore: false
|
||
)))
|
||
case "a", "b":
|
||
data = try JSONEncoder().encode(["session": sample(url.lastPathComponent)])
|
||
case "sessions":
|
||
data = try JSONEncoder().encode(["sessions": ["a", "b"].map { SummaryPatching.toSessionSummary(sample($0)) }])
|
||
case "machines":
|
||
data = Data(#"{"machines":[]}"#.utf8)
|
||
case "upload":
|
||
data = Data(#"{"success":true,"path":"/tmp/layout.txt"}"#.utf8)
|
||
case "delete":
|
||
data = Data(#"{"success":true}"#.utf8)
|
||
default:
|
||
status = 404
|
||
data = Data(#"{"error":"unused test endpoint"}"#.utf8)
|
||
}
|
||
return (data, try XCTUnwrap(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)))
|
||
}
|
||
|
||
private func sample(_ id: String) -> Session {
|
||
Session(id: id, namespace: "test", seq: 1, createdAt: 0, updatedAt: Int(Date.now.timeIntervalSince1970 * 1000),
|
||
active: true, activeAt: 80000,
|
||
metadata: SessionMetadata(path: "/workspace/hapi", host: "Mac", name: id == "a" ? "iPad 布局适配" : "Review navigation tests",
|
||
machineId: id == "a" ? "mac" : "debian", flavor: "codex"),
|
||
metadataVersion: 1, agentStateVersion: 1, thinking: false, thinkingAt: 0)
|
||
}
|
||
}
|