mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
461 lines
23 KiB
Swift
461 lines
23 KiB
Swift
import HapiClient
|
|
import HapiUI
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
@testable import HapiProtocol
|
|
|
|
/// Actual approval cards over deterministic HTTP; never activates a real chat,
|
|
/// reads Keychain credentials, or sends a permission decision to a hub.
|
|
@MainActor
|
|
final class PermissionActionPresentationTests: XCTestCase {
|
|
@Observable @MainActor
|
|
final class Driver {
|
|
var tool: ChatToolCall
|
|
var notice: String?
|
|
let http: ApprovalHTTP
|
|
let store: SessionListStore
|
|
let interactor: ChatInteractor
|
|
@ObservationIgnored var height: CGFloat = 0
|
|
@ObservationIgnored var appearances = 0
|
|
|
|
init(flavor: String = "claude", toolName: String = "Bash") async throws {
|
|
let input: JSONValue = .object(["command": .string("git diff -- ios/Hapi")])
|
|
tool = ChatToolCall(id: "tool", name: toolName, state: .pending, input: input,
|
|
createdAt: 0, permission: ToolPermission(id: "approval", status: .pending))
|
|
let session = Session(
|
|
id: "approval-test", namespace: "test", seq: 1, createdAt: 0, updatedAt: 0,
|
|
active: true, activeAt: 0,
|
|
metadata: SessionMetadata(path: "/workspace/hapi", host: "test", flavor: flavor),
|
|
metadataVersion: 1,
|
|
agentState: AgentState(requests: ["approval": AgentStateRequest(tool: toolName, arguments: input)]),
|
|
agentStateVersion: 1, thinking: false, thinkingAt: 0
|
|
)
|
|
http = ApprovalHTTP(session: session)
|
|
let url = try XCTUnwrap(URL(string: "http://127.0.0.1:1"))
|
|
let credentials = InMemoryCredentialStore()
|
|
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
|
|
try credentials.store(HubCredentials(hubUrl: url.absoluteString, accessToken: "test", jwt: "e30.\(payload).test"))
|
|
let auth = AuthManager(baseURL: url, credentialStore: credentials, performer: http)
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: http)
|
|
store = SessionListStore(api: api)
|
|
try await store.loadSessionDetail(session.id)
|
|
interactor = ChatInteractor(sessionId: session.id, api: api, sessionStore: store,
|
|
windows: MessageWindowControllers(provider: api))
|
|
interactor.onEvent = { [weak self] event in
|
|
if case .notice(let notice) = event { self?.notice = notice }
|
|
}
|
|
}
|
|
|
|
var block: ToolCallBlock {
|
|
ToolCallBlock(id: tool.id, localId: nil, createdAt: 0, invokedAt: nil,
|
|
durationMs: nil, usage: nil, model: nil, tool: tool, children: [], meta: nil)
|
|
}
|
|
|
|
func settlePermission(_ status: ToolPermissionStatus) {
|
|
store.applySessionEvent(.sessionUpdated(namespace: nil, sessionId: "approval-test", data: .patch(SessionPatch(
|
|
agentState: VersionedValue(version: 2, value: AgentState(requests: [:]))
|
|
))))
|
|
tool.permission = ToolPermission(id: "approval", status: status)
|
|
tool.state = .completed
|
|
}
|
|
}
|
|
|
|
actor ApprovalHTTP: HTTPPerforming {
|
|
struct Post: Sendable {
|
|
let path: String
|
|
let body: String?
|
|
}
|
|
let session: Session
|
|
var status = 200
|
|
var held = false
|
|
var waiters: [CheckedContinuation<Void, Never>] = []
|
|
private(set) var posts: [Post] = []
|
|
|
|
init(session: Session) { self.session = session }
|
|
func setStatus(_ value: Int) { status = value }
|
|
func hold() { held = true }
|
|
func release() {
|
|
held = false
|
|
let pending = waiters
|
|
waiters.removeAll()
|
|
pending.forEach { $0.resume() }
|
|
}
|
|
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
let url = try XCTUnwrap(request.url)
|
|
let data: Data
|
|
var responseStatus = 200
|
|
if request.httpMethod == "POST", url.path.contains("/permissions/") {
|
|
posts.append(Post(path: url.path, body: request.httpBody.map { String(decoding: $0, as: UTF8.self) }))
|
|
if held { await withCheckedContinuation { waiters.append($0) } }
|
|
responseStatus = status
|
|
data = Data((status == 200 ? #"{"ok":true}"# : #"{"error":"Approval failed"}"#).utf8)
|
|
} else if request.httpMethod == "GET", url.path == "/api/sessions/approval-test" {
|
|
data = try HapiJSON.encoder.encode(SessionResponse(session: session))
|
|
} else {
|
|
throw URLError(.unsupportedURL)
|
|
}
|
|
return (data, try XCTUnwrap(HTTPURLResponse(url: url, statusCode: responseStatus, httpVersion: nil, headerFields: nil)))
|
|
}
|
|
}
|
|
|
|
private struct Card: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
ToolCallBlockView(block: driver.block, basePath: "/workspace/hapi")
|
|
.environment(\.chatInteractions, driver.interactor)
|
|
.background(GeometryReader { geometry in
|
|
Color.clear
|
|
.onAppear { driver.height = geometry.size.height; driver.appearances += 1 }
|
|
.onChange(of: geometry.size.height) { _, height in driver.height = height }
|
|
})
|
|
.hapiReadingColumn()
|
|
}
|
|
}
|
|
|
|
private struct Specimen: View {
|
|
let driver: Driver
|
|
var theme = HapiTheme.light
|
|
var size = DynamicTypeSize.large
|
|
var locale = Locale(identifier: "en")
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
UserTextBlockView(block: UserTextBlock(
|
|
id: "user", localId: nil, createdAt: 0, invokedAt: nil,
|
|
text: "请检查一下 iOS 界面的改动。", attachments: nil,
|
|
status: nil, originalText: nil, meta: nil
|
|
)).hapiReadingColumn()
|
|
Card(driver: driver)
|
|
}
|
|
.padding(.vertical, 16)
|
|
}
|
|
.background(theme.background)
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
ChatComposerView(interactor: driver.interactor)
|
|
}
|
|
.navigationTitle("HAPI · Approval")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.hapiTypography().hapiTheme(theme)
|
|
.environment(\.dynamicTypeSize, size)
|
|
.environment(\.locale, locale)
|
|
.preferredColorScheme(theme.isDark ? .dark : .light)
|
|
}
|
|
}
|
|
|
|
func testFlavorAndToolGatesRetainTheirExistingPermissionSemantics() {
|
|
for (flavor, tool, deny, session, edits) in [
|
|
("claude", "Bash", PermissionAction.deny, true, false),
|
|
("claude", "Edit", .deny, false, true),
|
|
("claude", "ExitPlanMode", .deny, false, false),
|
|
("codex", "exec_command", .abort, true, false),
|
|
("codex", "Edit", .abort, true, false),
|
|
("cursor", "CursorBash", .abort, true, false),
|
|
("gemini", "GeminiBash", .abort, true, false),
|
|
] {
|
|
let options = PermissionActionOptions(flavor: flavor, toolName: tool)
|
|
XCTAssertEqual(options.denyAction, deny, "\(flavor), \(tool)")
|
|
XCTAssertEqual(options.canAllowForSession, session)
|
|
XCTAssertEqual(options.canAllowAllEdits, edits)
|
|
XCTAssertEqual(options.hasMore, session || edits)
|
|
}
|
|
XCTAssertEqual(PermissionActionOptions(flavor: nil, toolName: "CodexPermission").denyAction, .abort)
|
|
for tool in PermissionGates.editTools {
|
|
XCTAssertTrue(PermissionActionOptions(flavor: "claude", toolName: tool).canAllowAllEdits)
|
|
}
|
|
}
|
|
|
|
func testApprovalActionsHave44PointOuterTargetsAndNeverShiftWhenDisabled() {
|
|
for theme in [HapiTheme.light, .dark, .oled] {
|
|
for locale in ["en", "zh-Hans"] {
|
|
for tool in ["Bash", "Edit", "ExitPlanMode"] {
|
|
let options = PermissionActionOptions(flavor: "claude", toolName: tool)
|
|
let ready = PermissionActionsRow(options: options, requestId: "approval", resolving: false) { _ in }
|
|
.hapiTheme(theme).environment(\.locale, Locale(identifier: locale))
|
|
let busy = PermissionActionsRow(options: options, requestId: "approval", resolving: true) { _ in }
|
|
.hapiTheme(theme).environment(\.locale, Locale(identifier: locale))
|
|
for width: CGFloat in [288, 358, 720] {
|
|
let measured = measure(ready, width: width)
|
|
XCTAssertEqual(measured.height, 44, accuracy: 1, "The entire button, not just its label, is 44 pt")
|
|
XCTAssertLessThanOrEqual(measured.width, width)
|
|
XCTAssertEqual(measure(busy, width: width).height, measured.height, accuracy: 1)
|
|
}
|
|
for (width, size) in [(CGFloat(120), DynamicTypeSize.large), (288, .accessibility5)] {
|
|
let measured = measure(ready, width: width, size: size)
|
|
if options.hasMore || size.isAccessibilitySize { XCTAssertGreaterThan(measured.height, 44) }
|
|
if size.isAccessibilitySize {
|
|
XCTAssertGreaterThanOrEqual(measured.height, options.hasMore ? 3 * 44 + 16 : 2 * 44 + 8)
|
|
}
|
|
XCTAssertLessThanOrEqual(measured.width, width)
|
|
XCTAssertEqual(measure(busy, width: width, size: size).height, measured.height, accuracy: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func testSubmittingPreservesGeometryAndSendsOnlyOneDecisionUntilSSESettles() async throws {
|
|
let driver = try await Driver(flavor: "codex", toolName: "CodexBash")
|
|
let (window, _) = try host(Specimen(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await eventually { driver.height > 100 }
|
|
let initial = driver.height
|
|
await driver.http.hold()
|
|
defer { Task { await driver.http.release() } }
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .abort)
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .allow)
|
|
try await eventually { await driver.http.posts.count == 1 }
|
|
try await layoutSettles()
|
|
XCTAssertEqual(driver.height, initial, accuracy: 1)
|
|
XCTAssertEqual(driver.interactor.permissionOverrides["approval"], .resolving)
|
|
let posts = await driver.http.posts
|
|
let post = try XCTUnwrap(posts.first)
|
|
XCTAssertTrue(post.path.hasSuffix("/approval/deny"))
|
|
XCTAssertEqual(post.body, #"{"decision":"abort"}"#)
|
|
try capture(window, name: "submitting")
|
|
await driver.http.release()
|
|
try await layoutSettles()
|
|
XCTAssertEqual(driver.interactor.permissionOverrides["approval"], .resolving, "HTTP success alone is not a verdict")
|
|
driver.settlePermission(.denied)
|
|
try await eventually { driver.height < initial - 44 }
|
|
XCTAssertNil(driver.interactor.permissionOverrides["approval"])
|
|
try capture(window, name: "denied")
|
|
}
|
|
|
|
func testAlreadyHandledResponsesRemoveTheActionArea() async throws {
|
|
for status in [404, 409] {
|
|
let driver = try await Driver()
|
|
let (window, _) = try host(Specimen(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await eventually { driver.height > 100 }
|
|
let initial = driver.height
|
|
await driver.http.setStatus(status)
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .allow)
|
|
try await eventually { driver.interactor.permissionOverrides["approval"] == .alreadyHandled && driver.height < initial - 40 }
|
|
try capture(window, name: "handled-\(status)")
|
|
}
|
|
}
|
|
|
|
func testFailureRestoresActionsAndSessionApprovalKeepsItsExistingWireBody() async throws {
|
|
let driver = try await Driver()
|
|
let (window, _) = try host(Specimen(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await eventually { driver.height > 100 }
|
|
let initial = driver.height
|
|
await driver.http.setStatus(500)
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .allow)
|
|
try await eventually { driver.notice != nil }
|
|
XCTAssertNil(driver.interactor.permissionOverrides["approval"])
|
|
try await layoutSettles()
|
|
XCTAssertEqual(driver.height, initial, accuracy: 1)
|
|
await driver.http.setStatus(200)
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .allowForSession)
|
|
try await eventually { await driver.http.posts.count == 2 }
|
|
let posts = await driver.http.posts
|
|
let post = try XCTUnwrap(posts.last)
|
|
XCTAssertEqual(post.body, #"{"allowTools":["Bash(git diff -- ios/Hapi)"]}"#)
|
|
driver.settlePermission(.approved)
|
|
try await eventually { driver.height < initial - 44 }
|
|
}
|
|
|
|
private struct Row: Identifiable, Equatable {
|
|
let index: Int
|
|
var id: String { String(index) }
|
|
}
|
|
|
|
private struct Transcript: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
AnchoredTranscriptList(
|
|
items: (0..<40).map { Row(index: $0) }, historyVersion: 0, jumpToken: 0,
|
|
historyControlID: "-1", onViewport: { _ in }, onLayout: { _, _ in }
|
|
) { row in
|
|
AnyView(Group {
|
|
if row.index == 10 { Card(driver: driver) }
|
|
else { Text("Message \(row.id)").frame(maxWidth: .infinity).frame(height: 100) }
|
|
})
|
|
}
|
|
.hapiTypography()
|
|
}
|
|
}
|
|
|
|
func testRecyclingRetainsSubmissionAndSettlementPreservesTheReadingAnchor() async throws {
|
|
let driver = try await Driver()
|
|
let (window, host) = try host(Transcript(driver: driver))
|
|
defer { window.isHidden = true }
|
|
func find(_ view: UIView) -> UICollectionView? {
|
|
(view as? UICollectionView) ?? view.subviews.lazy.compactMap(find).first
|
|
}
|
|
let list = try XCTUnwrap(find(host.view))
|
|
try await eventually { list.numberOfItems(inSection: 0) == 40 }
|
|
func browse(_ index: Int) async throws {
|
|
list.delegate?.scrollViewWillBeginDragging?(list)
|
|
list.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
try await eventually { list.cellForItem(at: IndexPath(item: index, section: 0)) != nil }
|
|
try await layoutSettles()
|
|
}
|
|
try await browse(10)
|
|
let height = driver.height
|
|
let appearances = driver.appearances
|
|
await driver.http.hold()
|
|
defer { Task { await driver.http.release() } }
|
|
driver.interactor.resolvePermission(requestId: "approval", action: .allow)
|
|
try await eventually { await driver.http.posts.count == 1 }
|
|
try await browse(32)
|
|
try await browse(10)
|
|
XCTAssertGreaterThan(driver.appearances, appearances)
|
|
XCTAssertEqual(driver.interactor.permissionOverrides["approval"], .resolving)
|
|
XCTAssertEqual(driver.height, height, accuracy: 1)
|
|
let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 10, section: 0)))
|
|
let offset = cell.frame.minY - list.contentOffset.y
|
|
await driver.http.release()
|
|
driver.settlePermission(.approved)
|
|
try await eventually { driver.height < height - 44 }
|
|
try await layoutSettles()
|
|
XCTAssertEqual(cell.frame.minY - list.contentOffset.y, offset, accuracy: 1)
|
|
}
|
|
|
|
func testProcessAndReadOnlyApprovalsUseCompactStatusPresentation() async throws {
|
|
let driver = try await Driver()
|
|
let summary = toolSummaryPresentation(driver.tool, basePath: nil)
|
|
let approvalHeader = ToolSummaryRow(presentation: summary, state: .pending, showsStatus: false) {}
|
|
let genericHeader = ToolSummaryRow(presentation: summary, state: .pending) {}
|
|
XCTAssertLessThan(measure(approvalHeader, width: 320, size: .accessibility3).height,
|
|
measure(genericHeader, width: 320, size: .accessibility3).height)
|
|
let process = PendingPermissionFooter(tool: driver.tool, requestId: "approval", interactions: driver.interactor)
|
|
XCTAssertEqual(measure(process, width: 320).height, 44 + 8 + 44 + 24, accuracy: 1)
|
|
let readonly = PermissionStateRow(permission: try XCTUnwrap(driver.tool.permission))
|
|
XCTAssertLessThan(measure(readonly, width: 320).height, 44)
|
|
let pendingHeader = measure(ToolDetailHeader(block: driver.block, basePath: nil), width: 320).height
|
|
for state in [ToolCallState.running, .error] {
|
|
var block = driver.block
|
|
block.tool.state = state
|
|
XCTAssertGreaterThan(measure(ToolDetailHeader(block: block, basePath: nil), width: 320).height,
|
|
pendingHeader + 16, "Only the duplicate pending marker is hidden, not running/errors")
|
|
}
|
|
for status in [ToolPermissionStatus.approved, .denied, .resolved, .canceled] {
|
|
let verdict = PermissionStateRow(permission: ToolPermission(id: "approval", status: status))
|
|
XCTAssertLessThan(measure(verdict, width: 320).height, 44)
|
|
XCTAssertLessThanOrEqual(measure(verdict, width: 288, size: .accessibility5).width, 288)
|
|
}
|
|
}
|
|
|
|
func testQueuedActionsFitSmallColumnsLargeTypeAndDisabledRows() async throws {
|
|
let driver = try await Driver()
|
|
func row(enabled: Bool = true, uncertain: Bool = false, scheduled: Bool = false) -> QueuedRowView {
|
|
QueuedRowView(row: QueuedMessageRow(
|
|
id: "queued", localId: "local", text: "Update", attachmentNames: [],
|
|
scheduledAt: scheduled ? 1_800_000_000_000 : nil,
|
|
canAct: enabled, canSteer: !uncertain && !scheduled, indeterminate: uncertain
|
|
), interactor: driver.interactor)
|
|
}
|
|
for theme in [HapiTheme.light, .dark, .oled] {
|
|
XCTAssertEqual(measure(row().hapiTheme(theme), width: 358).height, 56, accuracy: 1)
|
|
for (width, size) in [(CGFloat(180), DynamicTypeSize.large), (288, .accessibility5), (720, .xxxLarge)] {
|
|
let enabled = measure(row().hapiTheme(theme), width: width, size: size)
|
|
XCTAssertLessThanOrEqual(enabled.width, width)
|
|
if width < 320 { XCTAssertGreaterThan(enabled.height, 56) }
|
|
XCTAssertEqual(measure(row(enabled: false).hapiTheme(theme), width: width, size: size).height,
|
|
enabled.height, accuracy: 1)
|
|
for specimen in [row(uncertain: true), row(scheduled: true)] {
|
|
XCTAssertLessThanOrEqual(measure(specimen.hapiTheme(theme), width: width, size: size).width, width)
|
|
}
|
|
}
|
|
}
|
|
for (name, theme, size) in [("queue", HapiTheme.light, DynamicTypeSize.large),
|
|
("queue-large-type", .dark, .accessibility3)] {
|
|
let view = ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
row()
|
|
row(uncertain: true)
|
|
row(scheduled: true)
|
|
}
|
|
.hapiReadingColumn().padding(.vertical, 24)
|
|
}
|
|
.background(theme.background)
|
|
.hapiTypography().hapiTheme(theme)
|
|
.environment(\.dynamicTypeSize, size)
|
|
.preferredColorScheme(theme.isDark ? .dark : .light)
|
|
let (window, _) = try host(view, width: 320)
|
|
defer { window.isHidden = true }
|
|
try await layoutSettles()
|
|
try capture(window, name: name)
|
|
}
|
|
}
|
|
|
|
func testApprovalSpecimensInThemesLocalesAndAccessibleSizes() async throws {
|
|
let cases: [(String, HapiTheme, DynamicTypeSize, CGFloat, String)] = [
|
|
("light", .light, .large, 390, "en"), ("dark", .dark, .large, 390, "en"),
|
|
("oled", .oled, .large, 390, "en"), ("small", .light, .large, 320, "en"),
|
|
("chinese", .light, .large, 390, "zh-Hans"),
|
|
("large-type", .dark, .accessibility3, 390, "en"),
|
|
("largest-type", .light, .accessibility5, 390, "zh-Hans"),
|
|
("ipad", .light, .large, 1024, "en"),
|
|
]
|
|
for (name, theme, size, width, locale) in cases {
|
|
let driver = try await Driver()
|
|
let (window, host) = try host(Specimen(driver: driver, theme: theme, size: size,
|
|
locale: Locale(identifier: locale)), width: width)
|
|
defer { window.isHidden = true }
|
|
try await eventually { driver.height > 100 }
|
|
try await layoutSettles()
|
|
if size == .large { XCTAssertLessThan(driver.height, 200, "A short approval should remain compact") }
|
|
XCTAssertNil(host.presentedViewController, "Approvals stay inline")
|
|
try capture(window, name: name)
|
|
if size.isAccessibilitySize, let scroll = findScroll(host.view) {
|
|
scroll.setContentOffset(CGPoint(x: 0, y: max(-scroll.adjustedContentInset.top,
|
|
scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom)), animated: false)
|
|
try await layoutSettles()
|
|
try capture(window, name: name + "-bottom")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func measure<V: View>(_ view: V, width: CGFloat, size: DynamicTypeSize = .large) -> CGSize {
|
|
UIHostingController(rootView: view.hapiTypography().environment(\.dynamicTypeSize, size))
|
|
.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
|
}
|
|
|
|
private func host<V: View>(_ view: V, width: CGFloat = 390) throws -> (UIWindow, UIHostingController<V>) {
|
|
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: view)
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
host.view.layoutIfNeeded()
|
|
return (window, host)
|
|
}
|
|
|
|
private func findScroll(_ view: UIView) -> UIScrollView? {
|
|
(view as? UIScrollView) ?? view.subviews.lazy.compactMap(findScroll).first
|
|
}
|
|
|
|
private func eventually(_ condition: () async -> Bool) async throws {
|
|
for _ in 0..<150 {
|
|
if await condition() { return }
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
XCTFail("Approval presentation did not settle")
|
|
}
|
|
|
|
private func layoutSettles() async throws {
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
}
|
|
|
|
private func capture(_ window: UIWindow, name: String) throws {
|
|
guard let path = ProcessInfo.processInfo.environment["HAPI_APPROVAL_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("approval-\(name).png"))
|
|
}
|
|
}
|