172 lines
8.0 KiB
Swift
172 lines
8.0 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import GhosttyTerminal
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import Testing
|
|
import UIKit
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite(.serialized, .requiresKeyWindow)
|
|
@MainActor
|
|
struct MobileTerminalBackgroundTests {
|
|
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal], [true, false])
|
|
func transportJourneyDisablesNativeInputWithoutReplacingTheSurface(
|
|
_ transport: HostTerminalProtocol, foreground: Bool
|
|
) async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Recovery", host: "test.invalid")
|
|
let workspace = MobileWorkspace(
|
|
descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)),
|
|
hosts: app.library, transientHost: host)
|
|
workspace.receiveTransportState(.connected)
|
|
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let previous = scene.keyWindow
|
|
let window = UIWindow(windowScene: scene)
|
|
let controller = UIHostingController(
|
|
rootView: MobileWorkspaceView(
|
|
library: app.library, commandGenerator: app.commandGenerator,
|
|
workspace: workspace, workspaces: [workspace.descriptor], selectWorkspace: { _ in },
|
|
renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false,
|
|
openSameDirectory: { _ in }, closeWorkspace: { _ in }, closeOtherWorkspaces: { _ in }
|
|
)
|
|
.environment(\.scenePhase, foreground ? .active : .inactive))
|
|
window.rootViewController = controller
|
|
window.makeKeyAndVisible()
|
|
defer {
|
|
workspace.disconnect()
|
|
window.isHidden = true
|
|
window.rootViewController = nil
|
|
previous?.makeKey()
|
|
}
|
|
try await settle(controller)
|
|
let native = try #require(workspace.terminal.attachedPlatformView)
|
|
let surface = try #require(workspace.terminal.surface)
|
|
#expect(native.becomeFirstResponder())
|
|
workspace.receiveTransportState(.recovering)
|
|
try await settle(controller)
|
|
#expect(!native.isFirstResponder)
|
|
#expect(!native.isUserInteractionEnabled)
|
|
#expect(workspace.terminal.surface === surface)
|
|
workspace.receiveTransportState(.connected)
|
|
try await Task.sleep(for: .milliseconds(750))
|
|
try await settle(controller)
|
|
#expect(native.isUserInteractionEnabled)
|
|
#expect(native.isFirstResponder == foreground)
|
|
#expect(workspace.terminal.attachedPlatformView === native)
|
|
#expect(workspace.terminal.surface === surface)
|
|
}
|
|
|
|
@Test func oscBackgroundFillsSafeAreaWithoutChangingTerminalGeometryOrIdentity() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let workspace = MobileWorkspace(
|
|
descriptor: .init(hostID: UUID(), title: "Background", kind: .ssh), hosts: app.library)
|
|
let regions = MobileRegionTestState()
|
|
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let previous = scene.keyWindow
|
|
let window = UIWindow(windowScene: scene)
|
|
let controller = UIHostingController(
|
|
rootView: MobileRegionTestHost(state: regions) {
|
|
MobileWorkspaceView(
|
|
library: app.library, commandGenerator: app.commandGenerator,
|
|
workspace: workspace, workspaces: [workspace.descriptor], selectWorkspace: { _ in },
|
|
renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false,
|
|
openSameDirectory: { _ in }, closeWorkspace: { _ in }, closeOtherWorkspaces: { _ in })
|
|
})
|
|
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
|
|
window.rootViewController = controller
|
|
window.makeKeyAndVisible()
|
|
defer {
|
|
window.isHidden = true
|
|
window.rootViewController = nil
|
|
previous?.makeKey()
|
|
workspace.disconnect()
|
|
}
|
|
try await settle(controller)
|
|
let native = try #require(workspace.terminal.attachedPlatformView)
|
|
native.resignFirstResponder()
|
|
try await settle(controller)
|
|
let surface = try #require(workspace.terminal.surface)
|
|
let frame = native.convert(native.bounds, to: controller.view)
|
|
let initial = workspace.terminal.backgroundColor
|
|
guard case .inMemory(let backend) = workspace.terminal.configuration.backend else {
|
|
Issue.record("Expected an in-memory terminal")
|
|
return
|
|
}
|
|
|
|
backend.receive("\u{1B}]11;#123456\u{07}")
|
|
backend.waitForPendingOutput()
|
|
try await settle(controller)
|
|
let changed = TerminalColor(red: 0x12, green: 0x34, blue: 0x56)
|
|
#expect(workspace.terminal.backgroundColor == changed)
|
|
try expectSafeArea(in: controller.view, color: changed)
|
|
#expect(native.convert(native.bounds, to: controller.view) == frame)
|
|
|
|
// Shrinking the available region models keyboard avoidance; the same
|
|
// native surface must survive both this and a reserved-region change.
|
|
controller.additionalSafeAreaInsets.bottom += 160
|
|
try await settle(controller)
|
|
#expect(native.bounds.height < frame.height)
|
|
regions.regions = [.init(kind: .occlusion, frame: CGRect(x: 0, y: 0, width: 40, height: 40))]
|
|
try await settle(controller)
|
|
#expect(workspace.terminal.attachedPlatformView === native)
|
|
#expect(workspace.terminal.surface === surface)
|
|
|
|
backend.receive("\u{1B}]111\u{07}")
|
|
backend.waitForPendingOutput()
|
|
try await settle(controller)
|
|
#expect(workspace.terminal.backgroundColor == initial)
|
|
try expectSafeArea(in: controller.view, color: initial)
|
|
|
|
backend.receive("\u{1B}]11;#123456\u{07}")
|
|
backend.waitForPendingOutput()
|
|
try await settle(controller)
|
|
try expectSafeArea(in: controller.view, color: changed)
|
|
workspace.journey.begin(
|
|
host: .init(name: "Connecting", host: "test.invalid"), route: [], purpose: .sshSession, username: "user")
|
|
try await settle(controller)
|
|
let color = try pixel(in: controller.view, y: 5)
|
|
#expect(!matches(color, changed))
|
|
#expect(workspace.terminal.surface === surface)
|
|
}
|
|
|
|
private func settle(_ controller: UIViewController) async throws {
|
|
controller.view.layoutIfNeeded()
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
controller.view.layoutIfNeeded()
|
|
}
|
|
|
|
private func expectSafeArea(in view: UIView, color: TerminalColor) throws {
|
|
#expect(matches(try pixel(in: view, y: 5), color))
|
|
#expect(matches(try pixel(in: view, y: view.bounds.height - 5), color))
|
|
}
|
|
|
|
private func matches(_ components: [UInt8], _ color: TerminalColor) -> Bool {
|
|
zip(components, [color.red, color.green, color.blue]).allSatisfy { abs(Int($0) - Int($1)) <= 4 }
|
|
}
|
|
|
|
private func pixel(in view: UIView, y: CGFloat) throws -> [UInt8] {
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 1
|
|
let image = UIGraphicsImageRenderer(bounds: view.bounds, format: format).image { context in
|
|
view.layer.render(in: context.cgContext)
|
|
}
|
|
let cropped = try #require(
|
|
image.cgImage?.cropping(to: CGRect(x: view.bounds.midX, y: y, width: 1, height: 1)))
|
|
var bytes = [UInt8](repeating: 0, count: 4)
|
|
let space = try #require(CGColorSpace(name: CGColorSpace.sRGB))
|
|
try bytes.withUnsafeMutableBytes { buffer in
|
|
let context = try #require(
|
|
CGContext(
|
|
data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4,
|
|
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
|
|
context.draw(cropped, in: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
}
|
|
return Array(bytes.prefix(3))
|
|
}
|
|
}
|