mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-29 23:11:42 +00:00
324 lines
15 KiB
Swift
324 lines
15 KiB
Swift
import HapiClient
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
@MainActor
|
|
final class ManualEntryPresentationTests: XCTestCase {
|
|
@Observable
|
|
fileprivate final class Driver {
|
|
var isPairing = false
|
|
var failure: PairingFailure?
|
|
var submissions: [ManualPairingForm.Submission] = []
|
|
var edits = 0
|
|
}
|
|
|
|
private struct Harness: View {
|
|
let driver: Driver
|
|
var locale = Locale(identifier: "en")
|
|
var typeSize: DynamicTypeSize = .large
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ManualPairingFormView(isPairing: driver.isPairing, failure: driver.failure) {
|
|
driver.failure = nil
|
|
driver.edits += 1
|
|
} onPair: { driver.submissions.append($0) }
|
|
.navigationTitle("Enter Hub Details")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.environment(\.locale, locale)
|
|
.environment(\.dynamicTypeSize, typeSize)
|
|
}
|
|
}
|
|
|
|
func testEmptyFieldsAndNextKeyThenExplicitSubmission() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
XCTAssertEqual(address.text, "")
|
|
XCTAssertEqual(token.text, "")
|
|
XCTAssertEqual(address.placeholder, "Domain or IP address")
|
|
XCTAssertEqual(address.accessibilityLabel, "Hub URL")
|
|
XCTAssertEqual(address.keyboardType, .URL)
|
|
XCTAssertEqual(address.autocapitalizationType, .none)
|
|
XCTAssertEqual(address.autocorrectionType, .no)
|
|
XCTAssertEqual(address.clearButtonMode, .whileEditing)
|
|
XCTAssertTrue(address.isFirstResponder)
|
|
|
|
type("Hub.Example.com", into: address)
|
|
_ = address.delegate?.textFieldShouldReturn?(address)
|
|
try await settle()
|
|
XCTAssertTrue(token.isFirstResponder)
|
|
XCTAssertEqual(address.text, "hub.example.com")
|
|
type(" Secret+Value:team ", into: token)
|
|
try await settle()
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertEqual(driver.submissions, [.init(hubURL: "https://hub.example.com", accessToken: "Secret+Value:team")])
|
|
}
|
|
|
|
func testBothSystemPasteLinkFormsFillEitherFieldWithoutSubmitting() async throws {
|
|
let links = [
|
|
("hapicompanion://bind?hub=https%3A%2F%2FHub.Example.com&code=Tok%2BValue%3Ateam", "hub.example.com"),
|
|
("https://app.hapi.run/?hub=http%3A%2F%2F192.168.1.20%3A3006&token=Tok%2BValue%3Ateam", "192.168.1.20:3006"),
|
|
]
|
|
for target in [ManualPairingForm.Field.address, .accessToken] {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
type("old.test", into: address)
|
|
type("old:namespace", into: token)
|
|
try await settle()
|
|
for (link, host) in links {
|
|
let input = target == .address ? address : token
|
|
// A complete link is an import even with a caret in old text.
|
|
let start = input.beginningOfDocument
|
|
let caret = try XCTUnwrap(input.textRange(from: start, to: start))
|
|
try paste(link, into: input, range: caret)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, host)
|
|
XCTAssertEqual(token.text, "Tok+Value:team")
|
|
XCTAssertTrue(driver.submissions.isEmpty, "Pasting never invokes the pairing callback")
|
|
}
|
|
}
|
|
}
|
|
|
|
func testURLPasteChangesOnlyAddressAndLaterEditsWinAtSubmission() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
try paste("hapicompanion://bind?hub=https://old.test&code=old", into: address)
|
|
try await settle()
|
|
for _ in 0..<2 {
|
|
try paste(" HTTP://New.Test:3006/path?q=1#f \n", into: address)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "new.test:3006")
|
|
XCTAssertEqual(token.text, "old")
|
|
}
|
|
type("new:team", into: token)
|
|
try await settle()
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertEqual(driver.submissions, [.init(hubURL: "http://new.test:3006", accessToken: "new:team")])
|
|
}
|
|
|
|
func testOrdinaryPasteHonorsSelectionAndDoesNotReadOrRewriteTheClipboard() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
type("prefix.test", into: address)
|
|
try await settle()
|
|
let start = address.beginningOfDocument
|
|
let end = try XCTUnwrap(address.position(from: start, offset: 6))
|
|
let range = try XCTUnwrap(address.textRange(from: start, to: end))
|
|
try paste("hub", into: address, range: range)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "hub.test")
|
|
let token = try field(.accessToken, in: window)
|
|
try paste(" \nAbC+%2B:namespace\n ", into: token)
|
|
try await settle()
|
|
XCTAssertEqual(token.text, "AbC+%2B:namespace")
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
}
|
|
|
|
func testSystemItemProvidersAcceptTextAndCopiedURLs() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
let link = "hapicompanion://bind?hub=https://provider.test&code=token:team"
|
|
for provider in [NSItemProvider(object: link as NSString), NSItemProvider(object: try XCTUnwrap(URL(string: link)) as NSURL)] {
|
|
type("", into: address)
|
|
type("", into: token)
|
|
try await settle()
|
|
XCTAssertTrue(address.canPaste([provider]))
|
|
address.paste(itemProviders: [provider])
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: .seconds(3))
|
|
while token.text != "token:team", clock.now < deadline {
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
}
|
|
XCTAssertEqual(address.text, "provider.test")
|
|
XCTAssertEqual(token.text, "token:team")
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
}
|
|
}
|
|
|
|
func testTypingPreservesSelectionAndClearingDoesNotRestoreTheImportedURL() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
try paste("https://hub.example.com", into: address)
|
|
try await settle()
|
|
let position = try XCTUnwrap(address.position(from: address.beginningOfDocument, offset: 3))
|
|
address.selectedTextRange = address.textRange(from: position, to: position)
|
|
address.sendActions(for: .editingChanged)
|
|
try await settle()
|
|
XCTAssertEqual(address.offset(from: address.beginningOfDocument, to: try XCTUnwrap(address.selectedTextRange).start), 3)
|
|
type("", into: address)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "")
|
|
let token = try field(.accessToken, in: window)
|
|
type("token", into: token)
|
|
try await settle()
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
XCTAssertEqual(address.text, "")
|
|
}
|
|
|
|
func testMalformedLinkStaysVisibleAndCannotSubmitAgainstExistingHub() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
type("original.test", into: address)
|
|
try await settle()
|
|
let broken = "https://app.hapi.run/?hub=https://new.test&token="
|
|
try paste(broken, into: token)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "original.test")
|
|
XCTAssertEqual(token.text, broken)
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
type("corrected:team", into: token)
|
|
try await settle()
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertEqual(driver.submissions, [.init(hubURL: "https://original.test", accessToken: "corrected:team")])
|
|
}
|
|
|
|
func testEditsClearOldFailureAndBusyFormIgnoresLatePasteAndReturn() async throws {
|
|
let driver = Driver()
|
|
driver.failure = .invalidAccessToken
|
|
let window = try makeWindow(Harness(driver: driver))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let token = try field(.accessToken, in: window)
|
|
try paste("hapicompanion://bind?hub=https://hub.test&code=token", into: address)
|
|
try await settle()
|
|
XCTAssertNil(driver.failure)
|
|
driver.isPairing = true
|
|
try await settle()
|
|
XCTAssertFalse(address.isEnabled)
|
|
XCTAssertFalse(token.isEnabled)
|
|
XCTAssertEqual(address.delegate?.textField?(address, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "late"), false)
|
|
XCTAssertEqual(token.delegate?.textFieldShouldClear?(token), false)
|
|
let edits = driver.edits
|
|
try paste("hapicompanion://bind?hub=http://wrong.test&code=wrong", into: address)
|
|
_ = token.delegate?.textFieldShouldReturn?(token)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "hub.test")
|
|
XCTAssertEqual(token.text, "token")
|
|
XCTAssertEqual(driver.edits, edits)
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
}
|
|
|
|
func testSmallScreenAndLargeTypeKeepTheAddressReadable() async throws {
|
|
for (locale, typeSize, name) in [
|
|
("en", DynamicTypeSize.large, "empty-en"),
|
|
("zh-Hans", .large, "empty-zh"),
|
|
("en", .accessibility3, "large-en"),
|
|
] {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver, locale: Locale(identifier: locale), typeSize: typeSize), width: 320)
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
let rect = address.convert(address.bounds, to: window)
|
|
XCTAssertGreaterThanOrEqual(rect.height, 44)
|
|
XCTAssertGreaterThan(rect.width, 130)
|
|
XCTAssertGreaterThanOrEqual(rect.minX, 0)
|
|
XCTAssertLessThanOrEqual(rect.maxX, window.bounds.width)
|
|
XCTAssertEqual(address.placeholder, locale == "zh-Hans" ? "域名或 IP 地址" : "Domain or IP address")
|
|
XCTAssertEqual(address.accessibilityLabel, locale == "zh-Hans" ? "Hub 地址" : "Hub URL")
|
|
if typeSize.isAccessibilitySize {
|
|
XCTAssertGreaterThan(try XCTUnwrap(address.font).pointSize, 25)
|
|
}
|
|
try capture(window, named: name)
|
|
}
|
|
}
|
|
|
|
func testImportedHTTPDetailsAreVisibleForReview() async throws {
|
|
let driver = Driver()
|
|
let window = try makeWindow(Harness(driver: driver, locale: Locale(identifier: "zh-Hans")))
|
|
defer { window.isHidden = true }
|
|
try await settle()
|
|
let address = try field(.address, in: window)
|
|
try paste("https://app.hapi.run/?hub=http%3A%2F%2F192.168.1.20%3A3006&token=demo%3Ateam", into: address)
|
|
try await settle()
|
|
XCTAssertEqual(address.text, "192.168.1.20:3006")
|
|
XCTAssertEqual(try field(.accessToken, in: window).text, "demo:team")
|
|
XCTAssertTrue(driver.submissions.isEmpty)
|
|
try capture(window, named: "imported-http-zh")
|
|
}
|
|
|
|
private func field(_ field: ManualPairingForm.Field, in window: UIWindow) throws -> UITextField {
|
|
let identifier = field == .address ? "pairing.address" : "pairing.accessToken"
|
|
func find(_ view: UIView) -> UITextField? {
|
|
if let field = view as? UITextField, field.accessibilityIdentifier == identifier { return field }
|
|
return view.subviews.lazy.compactMap(find).first
|
|
}
|
|
return try XCTUnwrap(find(window), identifier)
|
|
}
|
|
|
|
private func type(_ value: String, into field: UITextField) {
|
|
field.text = value
|
|
field.sendActions(for: .editingChanged)
|
|
}
|
|
|
|
private func paste(_ value: String, into field: UITextField, range: UITextRange? = nil) throws {
|
|
let delegate = try XCTUnwrap(field.pasteDelegate)
|
|
let range = try XCTUnwrap(range ?? field.textRange(from: field.beginningOfDocument, to: field.endOfDocument))
|
|
// Inject the system-delivered payload, never touch the user's clipboard.
|
|
_ = delegate.textPasteConfigurationSupporting?(field, performPasteOf: NSAttributedString(string: value), to: range)
|
|
}
|
|
|
|
private func makeWindow<V: View>(_ view: V, width: CGFloat = 390) throws -> UIWindow {
|
|
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: 900)
|
|
window.rootViewController = UIHostingController(rootView: view)
|
|
window.makeKeyAndVisible()
|
|
window.layoutIfNeeded()
|
|
return window
|
|
}
|
|
|
|
private func settle() async throws { try await Task.sleep(for: .milliseconds(220)) }
|
|
|
|
private func capture(_ window: UIWindow, named name: String) throws {
|
|
guard let path = ProcessInfo.processInfo.environment["HAPI_PAIRING_CAPTURE"] else { return }
|
|
let directory = URL(fileURLWithPath: path, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 2
|
|
let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent(name + ".png"))
|
|
}
|
|
}
|