Files
Termind/TFITests/MobileAssistantRenderingTests.swift
T

496 lines
24 KiB
Swift

import AgentKit
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileAssistantRenderingTests {
@Test func renderUserImageAlignment() async throws {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
for index in 0..<7 {
let size =
index.isMultiple(of: 2)
? CGSize(width: 240, height: 120)
: CGSize(width: 120, height: 240)
let image = UIGraphicsImageRenderer(size: size).image { context in
UIColor(
hue: CGFloat(index) / 7,
saturation: 0.7,
brightness: 0.85,
alpha: 1
).setFill()
context.fill(CGRect(origin: .zero, size: size))
}
let attachment = AgentImageAttachment(
label: "preview-\(index).png",
mimeType: "image/png",
data: try #require(image.pngData()),
pixelWidth: Int(size.width),
pixelHeight: Int(size.height)
)
conversation.attach(
ContextChip(
id: attachment.id,
kind: .image,
label: attachment.label,
image: attachment
)
)
}
conversation.appendUserTurn("Compare these images.")
assistant.library.insertAndSelect(conversation)
try await capture(
"user-images",
language: "en",
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
)
}
@Test func renderTranscriptWorkStates() async throws {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
for language in ["en", "zh-Hans", "zh-Hant"] {
for phase in AssistantTranscriptPreview.Phase.allCases {
let group = AssistantTranscriptPreview.work(
language: try #require(AppLanguage(rawValue: language)), phase: phase
)
let presentation = AssistantTranscriptPresentationState()
presentation.followDefault(true, for: .work(group.id))
for item in group.items {
switch item {
case .tool(let card):
presentation.followDefault(true, for: .tool(card.id))
case .tools(let batch):
presentation.followDefault(true, for: .toolBatch(batch.id))
for card in batch.cards {
presentation.followDefault(true, for: .tool(card.id))
}
case .reasoning, .message:
break
}
}
let row = MobileAssistantWorkRow(
group: group, presentation: presentation,
hostName: { _ in "Production" }
)
for dark in [false, true] {
try await capture(
"work-\(phase.rawValue)-\(dark ? "dark" : "light")", language: language,
view: ScrollView { row.padding(20) }
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, dark ? .dark : .light)
)
}
try await capture(
"work-\(phase.rawValue)-large-reduced", language: language,
view: ScrollView { row.padding(20) }
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, .accessibility2)
.environment(\.mobileListReduceMotion, true)
)
}
}
}
@Test func renderNativeAssistant() async throws {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
for language in ["en", "zh-Hans", "zh-Hant"] {
func text(_ english: String, _ simplified: String, _ traditional: String) -> String {
switch language {
case "zh-Hans": simplified
case "zh-Hant": traditional
default: english
}
}
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language)))
assistant.library.insertAndSelect(conversation)
conversation.composer.draft =
switch language {
case "zh-Hans": "先检查服务日志。"
case "zh-Hant": "先檢查服務日誌。"
default: "Check the service logs first."
}
conversation.attach(ContextChip(kind: .file, label: "service.log", content: "Example log"))
conversation.isPlanning = true
try await capture(
"chat", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = "/"
try await capture(
"slash", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = "/skill "
try await capture(
"slash-options", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = ""
conversation.beginCompaction()
try await capture(
"compact-running", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.failCompaction(
text("The context service is unavailable.", "上下文服务不可用。", "上下文服務目前無法使用。")
)
try await capture(
"compact-failed", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.endCompaction()
conversation.appendUserTurn(
text("Preserve the latest service diagnosis.", "保留最新的服务诊断。", "保留最新的服務診斷。")
)
let compacted = conversation.applyCompaction(
summary: text("Earlier context summary", "早期上下文摘要", "先前的上下文摘要"),
cut: conversation.transcript.count
)
#expect(compacted != nil)
#expect(conversation.timeline.contains { if case .compaction = $0 { true } else { false } })
try await capture(
"compact-completed", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.attachmentImportError = text(
"The selected attachment could not be imported.", "无法导入所选附件。", "無法匯入所選附件。")
try await capture(
"attachment-error", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.attachmentImportError = nil
let approvalRequest = AgentApprovalRequest(
invocation: AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "render-approval",
name: "write",
arguments: .object([
"path": .string("/srv/app/config.json"),
"content": .string("{\"maintenance\":true}"),
])
)
),
descriptor: AgentToolDescriptor(
name: "write",
summary: "Write a file",
inputSchema: .object([:]),
target: .local,
approvalPolicy: .ask
),
userIntent: text(
"Enable maintenance mode.", "启用维护模式。", "啟用維護模式。"),
localReasons: [
text(
"This action changes a local file.",
"此操作会修改本地文件。",
"此操作會修改本機檔案。"
)
]
)
let approval = Task { await conversation.requestApproval(approvalRequest) }
for _ in 0..<100 where conversation.pendingApproval == nil { await Task.yield() }
_ = try #require(conversation.pendingApproval)
try await capture(
"approval", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"approval-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
try await capture(
"approval-large", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, .accessibility2))
conversation.respondToApproval(approvalRequest.id, allow: false)
_ = await approval.value
conversation.handle(
.tasksUpdated([
AgentTask(
id: "verify",
title: text("Verify the selected approach", "验证所选方案", "驗證所選方案"),
status: .inProgress
)
]))
conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(
id: "deployment",
header: text("Deployment", "部署", "部署"),
prompt: text("Where should this run?", "应在哪里运行?", "應在哪裡執行?"),
kind: .choice(
AgentUserInputChoice(options: [
AgentUserInputOption(
id: "staging",
label: text("Staging", "预发布环境", "測試環境"),
detail: text(
"Validate before production.", "上线前先验证。", "上線前先進行驗證。"),
isRecommended: true
),
AgentUserInputOption(
id: "production",
label: text("Production", "生产环境", "正式環境"),
detail: text(
"Apply directly to live traffic.", "直接应用到线上流量。", "直接套用至正式流量。")
),
]))
)
]))
try await capture(
"question", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
conversation.setUserInputCustomAnswer(
text("A private canary", "私有金丝雀环境", "私有的金絲雀環境"),
for: "deployment"
)
try await capture(
"question-custom", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.cancelUserInput()
conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(
id: "checks",
header: text("Verification", "验证", "驗證"),
prompt: text("Which checks should run?", "应该运行哪些检查?", "應執行哪些檢查?"),
kind: .choice(
AgentUserInputChoice(
options: [
AgentUserInputOption(
id: "smoke",
label: text("Smoke tests", "冒烟测试", "冒煙測試"),
detail: text(
"Verify the critical path.", "验证关键路径。", "驗證關鍵路徑。"),
isRecommended: true
),
AgentUserInputOption(
id: "load",
label: text("Load tests", "负载测试", "負載測試"),
detail: text(
"Exercise expected peak traffic.", "模拟预期峰值流量。", "模擬預期尖峰流量。")
),
],
allowsMultipleAnswers: true,
customPlaceholder: text("Another check…", "其他检查…", "其他檢查…")
)
)
)
]))
conversation.toggleUserInputOption("smoke", in: "checks")
try await capture(
"question-multiple", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-multiple-large", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, .accessibility2))
conversation.cancelUserInput()
conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(
id: "secret",
header: text("Authentication", "身份验证", "身分驗證"),
prompt: text("Enter the one-time secret.", "输入一次性机密。", "輸入一次性機密資訊。"),
kind: .secret
)
]))
conversation.secretInputDraft = "rendering-secret"
try await capture(
"question-secret", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-secret-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
conversation.cancelUserInput()
let authenticationRequest = AgentSSHAuthenticationRequest(
invocationID: UUID(),
hostID: UUID(),
displayName: text("Production", "生产服务器", "正式環境伺服器"),
address: "prod.example.com",
mode: .identity,
username: "deploy"
)
let authentication = Task {
await conversation.requestSSHAuthentication(authenticationRequest)
}
for _ in 0..<100 where conversation.pendingSSHAuthentication == nil { await Task.yield() }
_ = try #require(conversation.pendingSSHAuthentication)
try await capture(
"ssh-authentication", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.respondToSSHAuthentication(.cancelled)
_ = await authentication.value
let observation = SSHHostKeyObservation(
hostname: "prod.example.com",
port: 22,
algorithm: .ed25519,
fingerprint: "SHA256:W8RAGx7gKfJxHk2m7M8y5XoQn4tS9cA6bP3dL1uV0eE"
)
let hostKeyRequest = AgentSSHHostKeyRequest(
invocationID: UUID(),
hostID: UUID(),
displayName: text("Production", "生产服务器", "正式環境伺服器"),
address: "prod.example.com",
prompt: SSHHostKeyPrompt(observation: observation, kind: .firstUse),
allowsPersistentTrust: true
)
let hostKey = Task {
await conversation.requestSSHHostKeyConfirmation(hostKeyRequest)
}
for _ in 0..<100 where conversation.pendingSSHHostKey == nil { await Task.yield() }
_ = try #require(conversation.pendingSSHHostKey)
try await capture(
"ssh-host-key-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
conversation.respondToSSHHostKey(.deny)
_ = await hostKey.value
conversation.archivedAt = .now
conversation.isPinned = false
try await capture(
"archived-chat", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
assistant.newConversation()
try await capture(
"empty", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
}
}
@Test func renderNativeConversationLibraries() async throws {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
for language in ["en", "zh-Hans", "zh-Hant"] {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let pinned = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language)))
pinned.isPinned = true
let ordinary = ConversationModel()
ordinary.customTitle =
switch language {
case "zh-Hans": "检查部署日志"
case "zh-Hant": "檢查部署日誌"
default: "Review deployment logs"
}
let streaming = ConversationModel(runState: .running)
streaming.customTitle =
switch language {
case "zh-Hans": "调查数据库延迟"
case "zh-Hant": "調查資料庫延遲"
default: "Investigate database latency"
}
assistant.library.insertAndSelect(pinned)
assistant.library.insertAndSelect(ordinary)
assistant.library.insertAndSelect(streaming)
try await capture(
"conversations", language: language,
view: NavigationStack {
MobileAssistantLibrary(
model: assistant, route: .conversations,
selected: {}, showArchived: {}
).navigationTitle("Conversations")
}.environment(\.locale, Locale(identifier: language)))
pinned.archivedAt = .now
pinned.isPinned = false
try await capture(
"archive", language: language,
view: NavigationStack {
MobileAssistantLibrary(
model: assistant, route: .archived,
selected: {}, showArchived: {}
).navigationTitle("Archived")
}.environment(\.locale, Locale(identifier: language)))
}
}
private func capture<V: View>(_ name: String, language: String, view: V) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(rootView: view)
window.rootViewController = controller
window.windowLevel = .alert + 2
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
}
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(700))
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
#expect(data.count > 10000)
let directory = FileManager.default.temporaryDirectory.appending(path: "assistant-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
let url = directory.appending(path: "\(device)-\(language)-\(name).png")
try data.write(to: url)
print("Assistant snapshot: \(url.path)")
}
}