Skip to content

fix: display offline workspaces #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 12, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Coder Desktop/Coder Desktop/About.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

enum About {
public static let repo: String = "https://github.com/coder/coder-desktop-macos"
private static var credits: NSAttributedString {
let coder = NSMutableAttributedString(
string: "Coder.com",
@@ -21,7 +22,7 @@ enum About {
string: "GitHub",
attributes: [
.foregroundColor: NSColor.labelColor,
.link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!,
.link: NSURL(string: About.repo)!,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
]
)
26 changes: 13 additions & 13 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
@@ -3,29 +3,29 @@ import SwiftUI

@MainActor
final class PreviewVPN: Coder_Desktop.VPNService {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var agents: [UUID: Coder_Desktop.Agent] = [
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
@Published var state: Coder_Desktop.VPNServiceState = .connected
@Published var menuState: VPNMenuState = .init(agents: [
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID()),
]
], workspaces: [:])
let shouldFail: Bool
let longError = "This is a long error to test the UI with long error messages"

140 changes: 140 additions & 0 deletions Coder Desktop/Coder Desktop/VPNMenuState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation
import SwiftUI
import VPNLib

struct Agent: Identifiable, Equatable, Comparable {
let id: UUID
let name: String
let status: AgentStatus
let hosts: [String]
let wsName: String
let wsID: UUID

// Agents are sorted by status, and then by name
static func < (lhs: Agent, rhs: Agent) -> Bool {
if lhs.status != rhs.status {
return lhs.status < rhs.status
}
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
}

// Hosts arrive sorted by length, the shortest looks best in the UI.
var primaryHost: String? { hosts.first }
}

enum AgentStatus: Int, Equatable, Comparable {
case okay = 0
case warn = 1
case error = 2
case off = 3

public var color: Color {
switch self {
case .okay: .green
case .warn: .yellow
case .error: .red
case .off: .gray
}
}

static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

struct Workspace: Identifiable, Equatable, Comparable {
let id: UUID
let name: String
var agents: Set<UUID>

static func < (lhs: Workspace, rhs: Workspace) -> Bool {
lhs.name.localizedCompare(rhs.name) == .orderedAscending
}
}

struct VPNMenuState {
var agents: [UUID: Agent] = [:]
var workspaces: [UUID: Workspace] = [:]
// Upserted agents that don't belong to any known workspace, have no FQDNs,
// or have any invalid UUIDs.
var invalidAgents: [Vpn_Agent] = []

mutating func upsertAgent(_ agent: Vpn_Agent) {
guard
let id = UUID(uuidData: agent.id),
let wsID = UUID(uuidData: agent.workspaceID),
var workspace = workspaces[wsID],
!agent.fqdn.isEmpty
else {
invalidAgents.append(agent)
return
}
// An existing agent with the same name, belonging to the same workspace
// is from a previous workspace build, and should be removed.
agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
.forEach { agents[$0.key] = nil }
workspace.agents.insert(id)
workspaces[wsID] = workspace

agents[id] = Agent(
id: id,
name: agent.name,
// If last handshake was not within last five minutes, the agent is unhealthy
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
// Remove trailing dot if present
hosts: agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 },
wsName: workspace.name,
wsID: wsID
)
}

mutating func deleteAgent(withId id: Data) {
guard let agentUUID = UUID(uuidData: id) else { return }
// Update Workspaces
if let agent = agents[agentUUID], var ws = workspaces[agent.wsID] {
ws.agents.remove(agentUUID)
workspaces[agent.wsID] = ws
}
agents[agentUUID] = nil
// Remove from invalid agents if present
invalidAgents.removeAll { invalidAgent in
invalidAgent.id == id
}
}

mutating func upsertWorkspace(_ workspace: Vpn_Workspace) {
guard let wsID = UUID(uuidData: workspace.id) else { return }
workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: [])
// Check if we can associate any invalid agents with this workspace
invalidAgents.filter { agent in
agent.workspaceID == workspace.id
}.forEach { agent in
invalidAgents.removeAll { $0 == agent }
upsertAgent(agent)
}
}

mutating func deleteWorkspace(withId id: Data) {
guard let wsID = UUID(uuidData: id) else { return }
agents.filter { _, value in
value.wsID == wsID
}.forEach { key, _ in
agents[key] = nil
}
workspaces[wsID] = nil
}

var sorted: [VPNMenuItem] {
var items = agents.values.map { VPNMenuItem.agent($0) }
// Workspaces with no agents are shown as offline
items += workspaces.filter { _, value in
value.agents.isEmpty
}.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) }
return items.sorted()
}

mutating func clear() {
agents.removeAll()
workspaces.removeAll()
}
}
64 changes: 8 additions & 56 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ import VPNLib
@MainActor
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var agents: [UUID: Agent] { get }
var menuState: VPNMenuState { get }
func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable {
final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)
var workspaces: [UUID: String] = [:]

@Published var tunnelState: VPNServiceState = .disabled
@Published var sysExtnState: SystemExtensionState = .uninstalled
@@ -56,7 +55,7 @@ final class CoderVPNService: NSObject, VPNService {
return tunnelState
}

@Published var agents: [UUID: Agent] = [:]
@Published var menuState: VPNMenuState = .init()

// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
@@ -85,11 +84,6 @@ final class CoderVPNService: NSObject, VPNService {
NotificationCenter.default.removeObserver(self)
}

func clearPeers() {
agents = [:]
workspaces = [:]
}

func start() async {
switch tunnelState {
case .disabled, .failed:
@@ -150,7 +144,7 @@ final class CoderVPNService: NSObject, VPNService {
do {
let msg = try Vpn_PeerUpdate(serializedBytes: data)
debugPrint(msg)
clearPeers()
menuState.clear()
applyPeerUpdate(with: msg)
} catch {
logger.error("failed to decode peer update \(error)")
@@ -159,53 +153,11 @@ final class CoderVPNService: NSObject, VPNService {

func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents
.compactMap { UUID(uuidData: $0.id) }
.forEach { agentID in
agents[agentID] = nil
}
update.deletedWorkspaces
.compactMap { UUID(uuidData: $0.id) }
.forEach { workspaceID in
workspaces[workspaceID] = nil
for (id, agent) in agents where agent.wsID == workspaceID {
agents[id] = nil
}
}

// Update workspaces
for workspaceProto in update.upsertedWorkspaces {
if let workspaceID = UUID(uuidData: workspaceProto.id) {
workspaces[workspaceID] = workspaceProto.name
}
}

for agentProto in update.upsertedAgents {
guard let agentID = UUID(uuidData: agentProto.id) else {
continue
}
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
continue
}
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
let newAgent = Agent(
id: agentID,
name: agentProto.name,
// If last handshake was not within last five minutes, the agent is unhealthy
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
wsName: workspaceName,
wsID: workspaceID
)

// An existing agent with the same name, belonging to the same workspace
// is from a previous workspace build, and should be removed.
agents
.filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID }
.forEach { agents[$0.key] = nil }

agents[agentID] = newAgent
}
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
update.deletedWorkspaces.forEach { menuState.deleteWorkspace(withId: $0.id) }
// Upsert workspaces before agents to populate agent workspace names
update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) }
update.upsertedAgents.forEach { menuState.upsertAgent($0) }
}
}

99 changes: 0 additions & 99 deletions Coder Desktop/Coder Desktop/Views/Agent.swift

This file was deleted.

20 changes: 14 additions & 6 deletions Coder Desktop/Coder Desktop/Views/Agents.swift
Original file line number Diff line number Diff line change
@@ -12,15 +12,23 @@ struct Agents<VPN: VPNService, S: Session>: View {
Group {
// Agents List
if vpn.state == .connected {
let sortedAgents = vpn.agents.values.sorted()
let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows)
ForEach(visibleData, id: \.id) { agent in
AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!)
let items = vpn.menuState.sorted
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
ForEach(visibleItems, id: \.id) { agent in
MenuItemView(item: agent, baseAccessURL: session.baseAccessURL!)
.padding(.horizontal, Theme.Size.trayMargin)
}
if vpn.agents.count > defaultVisibleRows {
if items.count == 0 {
Text("No workspaces!")
.font(.body)
.foregroundColor(.gray)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 2)
}
// Only show the toggle if there are more items to show
if items.count > defaultVisibleRows {
Toggle(isOn: $viewAll) {
Text(viewAll ? "Show Less" : "Show All")
Text(viewAll ? "Show less" : "Show all")
.font(.headline)
.foregroundColor(.gray)
.padding(.horizontal, Theme.Size.trayInset)
11 changes: 9 additions & 2 deletions Coder Desktop/Coder Desktop/Views/ButtonRow.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import SwiftUI

struct ButtonRowView<Label: View>: View {
init(highlightColor: Color = .accentColor, isSelected: Bool = false, label: @escaping () -> Label) {
self.highlightColor = highlightColor
self.isSelected = isSelected
self.label = label
}

let highlightColor: Color
@State private var isSelected: Bool = false
@ViewBuilder var label: () -> Label

@@ -12,8 +19,8 @@ struct ButtonRowView<Label: View>: View {
.padding(.horizontal, Theme.Size.trayPadding)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(isSelected ? Color.white : .primary)
.background(isSelected ? Color.accentColor.opacity(0.8) : .clear)
.foregroundStyle(isSelected ? .white : .primary)
.background(isSelected ? highlightColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in isSelected = hovering }
}
56 changes: 56 additions & 0 deletions Coder Desktop/Coder Desktop/Views/InvalidAgents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import SwiftUI
import VPNLib

struct InvalidAgentsButton<VPN: VPNService>: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var vpn: VPN
var msg: String {
"\(vpn.menuState.invalidAgents.count) invalid \(vpn.menuState.invalidAgents.count > 1 ? "agents" : "agent").."
}

var body: some View {
Button {
showAlert()
} label: {
ButtonRowView(highlightColor: .red) { Text(msg) }
}.buttonStyle(.plain)
}

// `.alert` from SwiftUI doesn't play nice when the calling view is in the
// menu bar.
private func showAlert() {
let formattedAgents = vpn.menuState.invalidAgents.map { agent in
let agent_id = if let agent_id = UUID(uuidData: agent.id) {
agent_id.uuidString
} else {
"Invalid ID: \(agent.id.base64EncodedString())"
}
let wsID = if let wsID = UUID(uuidData: agent.workspaceID) {
wsID.uuidString
} else {
"Invalid ID: \(agent.workspaceID.base64EncodedString())"
}
let lastHandshake = agent.hasLastHandshake ? "\(agent.lastHandshake)" : "Never"
return """
Agent Name: \(agent.name)
ID: \(agent_id)
Workspace ID: \(wsID)
Last Handshake: \(lastHandshake)
FQDNs: \(agent.fqdn)
Addresses: \(agent.ipAddrs)
"""
}.joined(separator: "\n\n")

let alert = NSAlert()
alert.messageText = "Invalid Agents"
alert.informativeText = """
Coder Desktop received invalid agents from the VPN. This should
never happen. Please open an issue on \(About.repo).
\(formattedAgents)
"""
alert.alertStyle = .warning
dismiss()
alert.runModal()
}
}
6 changes: 4 additions & 2 deletions Coder Desktop/Coder Desktop/Views/VPNMenu.swift
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
.disabled(vpnDisabled)
}
Divider()
Text("Workspace Agents")
Text("Workspaces")
.font(.headline)
.foregroundColor(.gray)
VPNState<VPN, S>()
@@ -37,11 +37,13 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
// Trailing stack
VStack(alignment: .leading, spacing: 3) {
TrayDivider()
if vpn.state == .connected, !vpn.menuState.invalidAgents.isEmpty {
InvalidAgentsButton<VPN>()
}
if session.hasSession {
Link(destination: session.baseAccessURL!.appending(path: "templates")) {
ButtonRowView {
Text("Create workspace")
EmptyView()
}
}.buttonStyle(.plain)
TrayDivider()
110 changes: 110 additions & 0 deletions Coder Desktop/Coder Desktop/Views/VPNMenuItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import SwiftUI

// Each row in the workspaces list is an agent or an offline workspace
enum VPNMenuItem: Equatable, Comparable, Identifiable {
case agent(Agent)
case offlineWorkspace(Workspace)

var wsName: String {
switch self {
case let .agent(agent): agent.wsName
case let .offlineWorkspace(workspace): workspace.name
}
}

var status: AgentStatus {
switch self {
case let .agent(agent): agent.status
case .offlineWorkspace: .off
}
}

var id: UUID {
switch self {
case let .agent(agent): agent.id
case let .offlineWorkspace(workspace): workspace.id
}
}

static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
switch (lhs, rhs) {
case let (.agent(lhsAgent), .agent(rhsAgent)):
lhsAgent < rhsAgent
case let (.offlineWorkspace(lhsWorkspace), .offlineWorkspace(rhsWorkspace)):
lhsWorkspace < rhsWorkspace
// Agents always appear before offline workspaces
case (.offlineWorkspace, .agent):
false
case (.agent, .offlineWorkspace):
true
}
}
}

struct MenuItemView: View {
let item: VPNMenuItem
let baseAccessURL: URL
@State private var nameIsSelected: Bool = false
@State private var copyIsSelected: Bool = false

private var itemName: AttributedString {
let name = switch item {
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).coder"
case .offlineWorkspace: "\(item.wsName).coder"
}

var formattedName = AttributedString(name)
formattedName.foregroundColor = .primary
if let range = formattedName.range(of: ".coder") {
formattedName[range].foregroundColor = .gray
}
return formattedName
}

private var wsURL: URL {
// TODO: CoderVPN currently only supports owned workspaces
baseAccessURL.appending(path: "@me").appending(path: item.wsName)
}

var body: some View {
HStack(spacing: 0) {
Link(destination: wsURL) {
HStack(spacing: Theme.Size.trayPadding) {
ZStack {
Circle()
.fill(item.status.color.opacity(0.4))
.frame(width: 12, height: 12)
Circle()
.fill(item.status.color.opacity(1.0))
.frame(width: 7, height: 7)
}
Text(itemName).lineLimit(1).truncationMode(.tail)
Spacer()
}.padding(.horizontal, Theme.Size.trayPadding)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(nameIsSelected ? .white : .primary)
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in nameIsSelected = hovering }
Spacer()
}.buttonStyle(.plain)
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(copyableDNS, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.symbolVariant(.fill)
.padding(3)
}.foregroundStyle(copyIsSelected ? .white : .primary)
.imageScale(.small)
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in copyIsSelected = hovering }
.buttonStyle(.plain)
.padding(.trailing, Theme.Size.trayMargin)
}
}
}
}
2 changes: 1 addition & 1 deletion Coder Desktop/Coder Desktop/Views/VPNState.swift
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@ struct VPNState<VPN: VPNService, S: Session>: View {
.font(.body)
.foregroundColor(.gray)
case (.disabled, _):
Text("Enable CoderVPN to see agents")
Text("Enable CoderVPN to see workspaces")
.font(.body)
.foregroundStyle(.gray)
case (.connecting, _), (.disconnecting, _):
52 changes: 41 additions & 11 deletions Coder Desktop/Coder DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
@@ -18,14 +18,14 @@ struct AgentsTests {
view = sut.environmentObject(vpn).environmentObject(session)
}

private func createMockAgents(count: Int) -> [UUID: Agent] {
private func createMockAgents(count: Int, status: AgentStatus = .okay) -> [UUID: Agent] {
Dictionary(uniqueKeysWithValues: (1 ... count).map {
let agent = Agent(
id: UUID(),
name: "dev",
status: .okay,
copyableDNS: "a\($0).example.com",
wsName: "a\($0)",
status: status,
hosts: ["a\($0).coder"],
wsName: "ws\($0)",
wsID: UUID()
)
return (agent.id, agent)
@@ -41,10 +41,21 @@ struct AgentsTests {
}
}

@Test func noAgents() async throws {
vpn.state = .connected
vpn.menuState = .init(agents: [:])

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
#expect(throws: Never.self) { try view.find(text: "No workspaces!") }
}
}
}

@Test
func agentsWhenVPNOn() throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2)
vpn.menuState = .init(agents: createMockAgents(count: Theme.defaultVisibleAgents + 2))

let forEach = try view.inspect().find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
@@ -55,24 +66,26 @@ struct AgentsTests {
@Test
func showAllToggle() async throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 7)
vpn.menuState = .init(agents: createMockAgents(count: 7))

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
#expect(try toggle.labelView().text().string() == "Show All")
var forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
#expect(try toggle.labelView().text().string() == "Show all")
#expect(try !toggle.isOn())

try toggle.tap()
toggle = try view.find(ViewType.Toggle.self)
var forEach = try view.find(ViewType.ForEach.self)
forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents + 2)
#expect(try toggle.labelView().text().string() == "Show Less")
#expect(try toggle.labelView().text().string() == "Show less")

try toggle.tap()
toggle = try view.find(ViewType.Toggle.self)
forEach = try view.find(ViewType.ForEach.self)
#expect(try toggle.labelView().text().string() == "Show All")
#expect(try toggle.labelView().text().string() == "Show all")
#expect(forEach.count == Theme.defaultVisibleAgents)
}
}
@@ -81,10 +94,27 @@ struct AgentsTests {
@Test
func noToggleFewAgents() throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 3)
vpn.menuState = .init(agents: createMockAgents(count: 3))

#expect(throws: (any Error).self) {
_ = try view.inspect().find(ViewType.Toggle.self)
}
}

@Test
func showOfflineWorkspace() async throws {
vpn.state = .connected
vpn.menuState = .init(
agents: createMockAgents(count: Theme.defaultVisibleAgents - 1),
workspaces: [UUID(): Workspace(id: UUID(), name: "offline", agents: .init())]
)

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
#expect(throws: Never.self) { try view.find(link: "offline.coder") }
}
}
}
}
2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import ViewInspector
class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var agents: [UUID: Coder_Desktop.Agent] = [:]
@Published var menuState: VPNMenuState = .init()
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?

211 changes: 211 additions & 0 deletions Coder Desktop/Coder DesktopTests/VPNMenuStateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
@testable import Coder_Desktop
import Testing
@testable import VPNLib

@MainActor
@Suite
struct VPNMenuStateTests {
var state = VPNMenuState()

@Test
mutating func testUpsertAgent_addsAgent() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "dev"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)

let storedAgent = try #require(state.agents[agentID])
#expect(storedAgent.name == "dev")
#expect(storedAgent.wsID == workspaceID)
#expect(storedAgent.wsName == "foo")
#expect(storedAgent.primaryHost == "foo.coder")
#expect(storedAgent.status == .okay)
}

@Test
mutating func testDeleteAgent_removesAgent() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)
state.deleteAgent(withId: agent.id)

#expect(state.agents[agentID] == nil)
}

@Test
mutating func testDeleteWorkspace_removesWorkspaceAndAgents() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)
state.deleteWorkspace(withId: workspaceID.uuidData)

#expect(state.agents[agentID] == nil)
#expect(state.workspaces[workspaceID] == nil)
}

@Test
mutating func testUpsertAgent_unhealthyAgent() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600))
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)

let storedAgent = try #require(state.agents[agentID])
#expect(storedAgent.status == .warn)
}

@Test
mutating func testUpsertAgent_replacesOldAgent() async throws {
let workspaceID = UUID()
let oldAgentID = UUID()
let newAgentID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let oldAgent = Vpn_Agent.with {
$0.id = oldAgentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600))
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(oldAgent)

let newAgent = Vpn_Agent.with {
$0.id = newAgentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1" // Same name as old agent
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(newAgent)

#expect(state.agents[oldAgentID] == nil)
let storedAgent = try #require(state.agents[newAgentID])
#expect(storedAgent.name == "agent1")
#expect(storedAgent.wsID == workspaceID)
#expect(storedAgent.primaryHost == "foo.coder")
#expect(storedAgent.status == .okay)
}

@Test
mutating func testUpsertWorkspace_addsOfflineWorkspace() async throws {
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let storedWorkspace = try #require(state.workspaces[workspaceID])
#expect(storedWorkspace.name == "foo")

var output = state.sorted
#expect(output.count == 1)
#expect(output[0].id == workspaceID)
#expect(output[0].wsName == "foo")

let agentID = UUID()
let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200))
$0.fqdn = ["foo.coder"]
}
state.upsertAgent(agent)

output = state.sorted
#expect(output.count == 1)
#expect(output[0].id == agentID)
#expect(output[0].wsName == "foo")
#expect(output[0].status == .okay)
}

@Test
mutating func testUpsertAgent_invalidAgent_noUUID() async throws {
let agent = Vpn_Agent.with {
$0.name = "invalidAgent"
$0.fqdn = ["invalid.coder"]
}

state.upsertAgent(agent)

#expect(state.agents.isEmpty)
#expect(state.invalidAgents.count == 1)
}

@Test
mutating func testUpsertAgent_outOfOrder() async throws {
let agentID = UUID()
let workspaceID = UUID()

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "orphanAgent"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["orphan.coder"]
}

state.upsertAgent(agent)
#expect(state.agents.isEmpty)
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "validWorkspace" })
#expect(state.agents.count == 1)
}

@Test
mutating func testDeleteInvalidAgent_removesInvalid() async throws {
let agentID = UUID()
let workspaceID = UUID()

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "invalidAgent"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["invalid.coder"]
}

state.upsertAgent(agent)
#expect(state.agents.isEmpty)
state.deleteAgent(withId: agentID.uuidData)
#expect(state.agents.isEmpty)
#expect(state.invalidAgents.isEmpty)
}
}
116 changes: 0 additions & 116 deletions Coder Desktop/Coder DesktopTests/VPNServiceTests.swift

This file was deleted.

2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ struct VPNStateTests {
try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
#expect(throws: Never.self) {
try view.find(text: "Enable CoderVPN to see agents")
try view.find(text: "Enable CoderVPN to see workspaces")
}
}
}