283 lines
15 KiB
Swift
283 lines
15 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@Suite @MainActor
|
|
struct HostIconTests {
|
|
@Test(arguments: HostGeneralIcon.allCases)
|
|
func generalIconsOverrideDetectionWithoutBecomingDistributionMatches(icon: HostGeneralIcon) throws {
|
|
let catalog = try fixtureCatalog()
|
|
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
|
|
#expect(
|
|
catalog.resolvedArtwork(overrideID: icon.id, distribution: ubuntu) == .systemImage(icon.systemImageName))
|
|
#expect(catalog.resolvedArtwork(overrideID: icon.id, distribution: nil) == .systemImage(icon.systemImageName))
|
|
#expect(catalog.resolvedEntry(overrideID: icon.id, distribution: ubuntu) == nil)
|
|
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: ubuntu) == .asset("LinuxDistro-ubuntu"))
|
|
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: nil) == .systemImage("server.rack"))
|
|
}
|
|
|
|
@Test func artworkPreservesMatchingAndUnknownOverrideFallbacks() throws {
|
|
let catalog = try fixtureCatalog()
|
|
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
|
|
#expect(catalog.resolvedArtwork(overrideID: "future-icon", distribution: ubuntu) == .systemImage("server.rack"))
|
|
#expect(catalog.resolvedArtwork(overrideID: "windows", distribution: ubuntu) == .asset("HostOS-windows"))
|
|
#expect(
|
|
catalog.resolvedArtwork(
|
|
overrideID: nil, distribution: DetectedDistribution(id: "custom", likeIDs: ["debian"]))
|
|
== .asset("LinuxDistro-debian"))
|
|
#expect(
|
|
catalog.resolvedArtwork(overrideID: nil, distribution: DetectedDistribution(id: "new-linux"))
|
|
== .asset("HostOS-linux"))
|
|
}
|
|
|
|
@Test func darwinResolvesWithoutLinuxMetadata() throws {
|
|
for kernel in ["Darwin", "darwin", "DARWIN"] {
|
|
let info = RemoteSystemInfo(kernelName: kernel, kernelRelease: "25.0", architecture: "arm64")
|
|
#expect(info.distributionDetection == .resolved(DetectedDistribution(id: "macos")))
|
|
#expect(info.distributionID == nil)
|
|
#expect(!info.factLines.contains("distribution_id: macos"))
|
|
}
|
|
#expect(RemoteSystemInfo(kernelName: "Windows_NT").distributionDetection == .resolved(nil))
|
|
}
|
|
|
|
@Test func manualArtworkNeverChangesDetectionAndAutomaticUsesLatestProbe() throws {
|
|
let catalog = try fixtureCatalog()
|
|
let host = Host(
|
|
name: "Server", host: "server.invalid", detectedDistribution: DetectedDistribution(id: "ubuntu"))
|
|
let library = HostLibraryModel(hosts: [host])
|
|
var draft = host
|
|
draft.iconOverrideID = "windows"
|
|
#expect(library.host(host.id)?.iconOverrideID == nil)
|
|
library.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: host.id)
|
|
var reconnects = 0
|
|
library.didChangeHostConnection = { _ in reconnects += 1 }
|
|
library.didChangeTelnetConnection = { _ in reconnects += 1 }
|
|
#expect(library.saveHost(draft))
|
|
var saved = try #require(library.host(host.id))
|
|
#expect(saved.iconOverrideID == "windows")
|
|
#expect(saved.detectedDistribution?.id == "macos")
|
|
#expect(
|
|
catalog.resolvedEntry(overrideID: saved.iconOverrideID, distribution: saved.detectedDistribution)?.id
|
|
== "windows")
|
|
library.recordRemoteSystemInfo(
|
|
RemoteSystemInfo(distributionID: "debian", kernelName: "Linux"), for: host.id)
|
|
saved = try #require(library.host(host.id))
|
|
#expect(saved.iconOverrideID == "windows")
|
|
saved.iconOverrideID = nil
|
|
#expect(library.saveHost(saved))
|
|
#expect(
|
|
catalog.resolvedEntry(overrideID: nil, distribution: library.host(host.id)?.detectedDistribution)?.id
|
|
== "debian")
|
|
#expect(reconnects == 0)
|
|
}
|
|
|
|
@Test func catalogResolvesOverridesAndPreservesLinuxFallbacks() throws {
|
|
let catalog = try fixtureCatalog()
|
|
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
|
|
#expect(catalog.resolvedEntry(overrideID: "macos", distribution: ubuntu)?.asset == "HostOS-macos")
|
|
#expect(catalog.resolvedEntry(overrideID: "windows", distribution: ubuntu)?.asset == "HostOS-windows")
|
|
#expect(catalog.resolvedEntry(overrideID: "server", distribution: ubuntu) == nil)
|
|
#expect(catalog.resolvedEntry(overrideID: "future-os", distribution: ubuntu) == nil)
|
|
#expect(catalog.resolvedEntry(overrideID: nil, distribution: ubuntu)?.asset == "LinuxDistro-ubuntu")
|
|
#expect(
|
|
catalog.entry(matching: try #require(DetectedDistribution(id: "custom", likeIDs: ["debian"])))?.id
|
|
== "debian")
|
|
#expect(catalog.selectableEntries.map(\.id) == ["bsd", "debian", "linux", "macos", "ubuntu", "windows"])
|
|
#expect(Set(catalog.entries.map(\.id)).count == catalog.entries.count)
|
|
}
|
|
|
|
private func fixtureCatalog() throws -> HostIconCatalog {
|
|
try JSONDecoder().decode(
|
|
HostIconCatalog.self,
|
|
from: Data(
|
|
"""
|
|
{"entries":[
|
|
{"id":"ubuntu","title":"Ubuntu","asset":"LinuxDistro-ubuntu","ids":["ubuntu"],"hex":"E95420"},
|
|
{"id":"debian","title":"Debian","asset":"LinuxDistro-debian","ids":["debian"],"hex":"A81D33"},
|
|
{"id":"macos","title":"macOS","asset":"HostOS-macos","ids":["macos"],"hex":"000000"},
|
|
{"id":"windows","title":"Windows","asset":"HostOS-windows","ids":["windows"],"hex":"0078D4"},
|
|
{"id":"linux","title":"Linux","asset":"HostOS-linux","ids":["linux"],"hex":"FCC624"},
|
|
{"id":"bsd","title":"BSD","asset":"HostOS-bsd","ids":["bsd","dragonfly"],"hex":"AB2B28"}
|
|
]}
|
|
""".utf8))
|
|
}
|
|
|
|
@Test func genericArtworkDoesNotReplaceExactMatchesOrManualChoices() throws {
|
|
let catalog = try fixtureCatalog()
|
|
let unknown = try #require(DetectedDistribution(id: "new-linux"))
|
|
#expect(catalog.entry(matching: unknown)?.id == "linux")
|
|
#expect(catalog.entry(matching: try #require(DetectedDistribution(id: "dragonfly")))?.id == "bsd")
|
|
#expect(
|
|
catalog.entry(
|
|
matching: try #require(DetectedDistribution(id: "custom", likeIDs: ["missing", "debian", "ubuntu"])))?
|
|
.id
|
|
== "debian")
|
|
#expect(
|
|
catalog.entry(matching: try #require(DetectedDistribution(id: "ubuntu", likeIDs: ["debian"])))?.id
|
|
== "ubuntu")
|
|
#expect(catalog.resolvedEntry(overrideID: "server", distribution: unknown) == nil)
|
|
#expect(catalog.resolvedEntry(overrideID: "future-icon", distribution: unknown) == nil)
|
|
#expect(catalog.resolvedEntry(overrideID: nil, distribution: nil) == nil)
|
|
}
|
|
|
|
@Test func alphabeticalOrderIgnoresCaseAndUsesStableIDsForTies() throws {
|
|
var catalog = try fixtureCatalog()
|
|
let template = try #require(catalog.entries.first)
|
|
catalog.entries = [("z", "alpha"), ("b", "Beta"), ("a", "ALPHA"), ("w", "Windows")].map { id, title in
|
|
var entry = template
|
|
entry.id = id
|
|
entry.title = title
|
|
return entry
|
|
}
|
|
#expect(catalog.selectableEntries.map(\.id) == ["a", "z", "b", "w"])
|
|
catalog.entries.reverse()
|
|
#expect(catalog.selectableEntries.map(\.id) == ["a", "z", "b", "w"])
|
|
}
|
|
|
|
@Test func legacyDomainJSONDefaultsToAutomaticAndUnknownIDsSurvive() throws {
|
|
let host = Host(name: "Host", host: "example.invalid")
|
|
let data = try JSONEncoder().encode(host)
|
|
#expect(try JSONDecoder().decode(Host.self, from: data).iconOverrideID == nil)
|
|
for id in HostGeneralIcon.allCases.map(\.id) + ["windows", "macos", "future-icon"] {
|
|
var manual = host
|
|
manual.iconOverrideID = id
|
|
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(manual)) == manual)
|
|
#expect(PersistentHost(manual).value == manual)
|
|
}
|
|
}
|
|
|
|
@Test func savedChoicesSyncAndCanBeCleared() throws {
|
|
let source = try TermindPersistence.makeContainer(inMemory: true)
|
|
let destination = try TermindPersistence.makeContainer(inMemory: true)
|
|
let store = HostPersistence(context: source.mainContext)
|
|
let remote = HostPersistence(context: destination.mainContext)
|
|
let library = HostLibraryModel(persistence: store)
|
|
var host = Host(
|
|
name: "Host", host: "example.invalid", detectedDistribution: DetectedDistribution(id: "dragonfly"))
|
|
#expect(library.saveHost(host))
|
|
host = try #require(library.host(host.id))
|
|
let journal = VaultSyncJournal(context: source.mainContext)
|
|
let choices: [String?] =
|
|
HostGeneralIcon.allCases.map { $0.id } + [
|
|
"windows", "macos", "freebsd", "bsd", "linux", "future-icon", nil,
|
|
]
|
|
for choice in choices {
|
|
host.iconOverrideID = choice
|
|
#expect(library.saveHost(host))
|
|
let envelope = try #require(try journal.envelope(for: .init(kind: .host, id: host.id)))
|
|
#expect(envelope.formatVersion == SyncRecordFormats.vault.writingVersion(for: "host"))
|
|
_ = try remote.applyRemote([VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))])
|
|
#expect(try remote.load().hosts.first?.iconOverrideID == choice)
|
|
#expect(try remote.load().hosts.first?.detectedDistribution?.id == "dragonfly")
|
|
}
|
|
}
|
|
|
|
@Test func tailscaleChoicesSurviveRediscoveryAndWireRoundTrips() throws {
|
|
var group = HostGroup(name: "Tailnet")
|
|
group.tailscale = TailscaleGroupConfiguration(
|
|
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net"))
|
|
let peer = TailscalePeer(
|
|
stableID: "n1", name: "Mac", dnsName: "mac.example.ts.net", online: true, sshHostKeys: ["ssh-ed25519 test"])
|
|
let library = HostLibraryModel(groups: [group])
|
|
library.replaceTailscalePeers([peer], groupID: group.id)
|
|
var host = try #require(library.hosts.first)
|
|
for choice: String? in ["windows", "cloud", "virtual_machine", nil] {
|
|
host.iconOverrideID = choice
|
|
#expect(library.saveTailscaleHost(host))
|
|
library.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: host.id)
|
|
library.replaceTailscalePeers([peer], groupID: group.id)
|
|
let projected = try #require(library.host(host.id))
|
|
#expect(projected.iconOverrideID == choice)
|
|
#expect(projected.detectedDistribution?.id == "macos")
|
|
let payload = VaultRecordPayload.hostGroup(try #require(library.group(group.id)))
|
|
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
|
|
let restored = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
|
|
#expect(restored == envelope)
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let store = HostPersistence(context: container.mainContext)
|
|
_ = try store.applyRemote([restored])
|
|
let other = HostLibraryModel(persistence: store)
|
|
other.replaceTailscalePeers([peer], groupID: group.id)
|
|
#expect(other.host(host.id)?.iconOverrideID == choice)
|
|
}
|
|
}
|
|
|
|
@Test func olderWireFormatsRejectLossyWritesAndDefaultToAutomatic() throws {
|
|
var host = Host(name: "Host", host: "example.invalid", sessionPersistence: .init(enabled: false))
|
|
for version in [1, 2, 4] {
|
|
host.iconOverrideID = nil
|
|
var envelope = VaultEnvelope(
|
|
key: .init(kind: .host, id: host.id), version: .baseline(writerID: UUID()),
|
|
payload: .host(host), formatVersion: version)
|
|
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
|
|
host.iconOverrideID = "windows"
|
|
envelope.payload = .host(host)
|
|
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
|
|
}
|
|
for version in [1, 2, 4, 5] {
|
|
let deletion = VaultEnvelope(
|
|
key: .init(kind: .host, id: host.id), version: .baseline(writerID: UUID()),
|
|
isDeleted: true, formatVersion: version)
|
|
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(deletion)) == deletion)
|
|
}
|
|
}
|
|
|
|
@Test func failedSaveDoesNotPublishDraftArtwork() throws {
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let store = HostPersistence(context: container.mainContext)
|
|
let library = HostLibraryModel(persistence: store)
|
|
let host = Host(name: "Host", host: "example.invalid")
|
|
#expect(library.saveHost(host))
|
|
let before = try #require(library.host(host.id))
|
|
let journal = VaultSyncJournal(context: container.mainContext)
|
|
let key = VaultRecordKey(kind: .host, id: host.id)
|
|
let intent = try journal.envelope(for: key)
|
|
let corrupt = try PersistentWorkspaceTemplate(WorkspaceTemplate(name: "Broken", layout: .pane(.empty)))
|
|
corrupt.layoutData = Data("invalid".utf8)
|
|
container.mainContext.insert(corrupt)
|
|
try container.mainContext.save()
|
|
var draft = before
|
|
draft.iconOverrideID = "windows"
|
|
#expect(!library.saveHost(draft))
|
|
#expect(library.host(host.id) == before)
|
|
#expect(try journal.envelope(for: key) == intent)
|
|
}
|
|
|
|
@Test func version53StoreMigratesThenRetainsArtworkOnReopen() throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("Hosts.store")
|
|
var host = Host(
|
|
name: "Existing", host: "example.invalid", port: 2222, tags: ["production"], colorTag: .teal,
|
|
detectedDistribution: DetectedDistribution(id: "ubuntu"),
|
|
sessionPersistence: .init(enabled: true))
|
|
do {
|
|
let schema = Schema(versionedSchema: TermindSchemaV53.self)
|
|
let legacy = try ModelContainer(
|
|
for: schema,
|
|
configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none))
|
|
legacy.mainContext.insert(TermindSchemaV53HostModels.PersistentHost(host))
|
|
try legacy.mainContext.save()
|
|
}
|
|
do {
|
|
let migrated = try TermindPersistence.makeContainer(url: url)
|
|
let store = HostPersistence(context: migrated.mainContext)
|
|
#expect(try store.load().hosts == [host])
|
|
host.iconOverrideID = "macos"
|
|
_ = try store.save(.init(hosts: [host]))
|
|
}
|
|
let reopened = try TermindPersistence.makeContainer(url: url)
|
|
#expect(try HostPersistence(context: reopened.mainContext).load().hosts == [host])
|
|
}
|
|
}
|