Skip to content

feat: add login flow & session management #10

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 4 commits into from
Dec 12, 2024
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
2 changes: 2 additions & 0 deletions Coder Desktop/.swiftlint.yml
Original file line number Diff line number Diff line change
@@ -3,3 +3,5 @@ disabled_rules:
- trailing_comma
type_name:
allowed_symbols: "_"
identifier_name:
allowed_symbols: "_"
19 changes: 19 additions & 0 deletions Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
AA8BC3392D0060A900E1ABAA /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC3382D0060A900E1ABAA /* ViewInspector */; };
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */; };
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */; };
AAD720D02D0816B200F6304D /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = AAD720CF2D0816B200F6304D /* Alamofire */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
@@ -101,6 +102,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AAD720D02D0816B200F6304D /* Alamofire in Frameworks */,
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */,
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */,
);
@@ -188,6 +190,7 @@
packageProductDependencies = (
AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */,
AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */,
AAD720CF2D0816B200F6304D /* Alamofire */,
);
productName = "Coder Desktop";
productReference = 961678FC2CFF100D00B2B6DF /* Coder Desktop.app */;
@@ -302,6 +305,7 @@
AA8BC33A2D0060C500E1ABAA /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */,
AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */,
AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */,
AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 961678FD2CFF100D00B2B6DF /* Products */;
@@ -533,6 +537,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -561,6 +566,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -778,6 +784,14 @@
kind = branch;
};
};
AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/Alamofire";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.10.2;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
@@ -801,6 +815,11 @@
package = AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */;
productName = KeychainAccess;
};
AAD720CF2D0816B200F6304D /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
package = AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 961678F42CFF100D00B2B6DF /* Project object */;

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
BuildableName = "Coder Desktop.app"
BlueprintName = "Coder Desktop"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Coder Desktop.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9616790E2CFF100E00B2B6DF"
BuildableName = "Coder DesktopTests.xctest"
BlueprintName = "Coder DesktopTests"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961679182CFF100E00B2B6DF"
BuildableName = "Coder DesktopUITests.xctest"
BlueprintName = "Coder DesktopUITests"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
BuildableName = "Coder Desktop.app"
BlueprintName = "Coder Desktop"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
BuildableName = "Coder Desktop.app"
BlueprintName = "Coder Desktop"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
37 changes: 37 additions & 0 deletions Coder Desktop/Coder Desktop.xctestplan
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"configurations" : [
{
"id" : "BB7F7563-199E-4896-BCDE-1F751C24B71F",
"name" : "Test Scheme Action",
"options" : {

}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:Coder Desktop.xcodeproj",
"identifier" : "961678FB2CFF100D00B2B6DF",
"name" : "Coder Desktop"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:Coder Desktop.xcodeproj",
"identifier" : "9616790E2CFF100E00B2B6DF",
"name" : "Coder DesktopTests"
}
},
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:Coder Desktop.xcodeproj",
"identifier" : "961679182CFF100E00B2B6DF",
"name" : "Coder DesktopUITests"
}
}
],
"version" : 1
}
47 changes: 47 additions & 0 deletions Coder Desktop/Coder Desktop/About.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import SwiftUI

enum About {
private static var credits: NSAttributedString {
let coder = NSMutableAttributedString(
string: "Coder.com",
attributes: [
.foregroundColor: NSColor.labelColor,
.link: NSURL(string: "https://coder.com")!,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
]
)
let separator = NSAttributedString(
string: " | ",
attributes: [
.foregroundColor: NSColor.labelColor,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
]
)
let source = NSAttributedString(
string: "GitHub",
attributes: [
.foregroundColor: NSColor.labelColor,
.link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
]
)
coder.append(separator)
coder.append(source)
return coder
}

static func open() {
#if compiler(>=5.9) && canImport(AppKit)
if #available(macOS 14, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
#else
NSApp.activate(ignoringOtherApps: true)
#endif
NSApp.orderFrontStandardAboutPanel(options: [
.credits: credits,
])
}
}
32 changes: 22 additions & 10 deletions Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SwiftUI
import FluidMenuBarExtra
import SwiftUI

@main
struct DesktopApp: App {
@@ -10,21 +10,33 @@ struct DesktopApp: App {
MenuBarExtra("", isInserted: $hidden) {
EmptyView()
}
Window("Sign In", id: Windows.login.rawValue) {
LoginForm<PreviewClient, PreviewSession>()
}.environmentObject(appDelegate.session)
.windowResizability(.contentSize)
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
private var menuBarExtra: FluidMenuBarExtra?
// TODO: Replace with real implementations
private var vpn = PreviewVPN()
private var session = PreviewSession()
let vpn: PreviewVPN
let session: PreviewSession

override init() {
// TODO: Replace with real implementations
vpn = PreviewVPN()
session = PreviewSession()
}

func applicationDidFinishLaunching(_ notification: Notification) {
self.menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
VPNMenu(
vpn: self.vpn,
session: self.session
).frame(width: 256)
func applicationDidFinishLaunching(_: Notification) {
menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
.environmentObject(self.vpn)
.environmentObject(self.session)
}
}

func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
false
}
}
24 changes: 24 additions & 0 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

struct PreviewClient: Client {
init(url _: URL, token _: String? = nil) {}

func user(_: String) async throws -> User {
try await Task.sleep(for: .seconds(1))
return User(
id: UUID(),
username: "admin",
avatar_url: "",
name: "admin",
email: "admin@coder.com",
created_at: Date.now,
updated_at: Date.now,
last_seen_at: Date.now,
status: "active",
login_type: "none",
theme_preference: "dark",
organization_ids: [],
roles: []
)
}
}
Original file line number Diff line number Diff line change
@@ -11,15 +11,14 @@ class PreviewSession: Session {
baseAccessURL = nil
}

func login(baseAccessURL: URL, sessionToken: String) {
func store(baseAccessURL: URL, sessionToken: String) {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
}

func logout() {
func clear() {
hasSession = false
self.baseAccessURL = nil
self.sessionToken = nil
sessionToken = nil
}
}
10 changes: 4 additions & 6 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
@@ -5,15 +5,13 @@ class PreviewVPN: Coder_Desktop.VPNService {
@Published var agents: [Coder_Desktop.Agent] = [
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
workspaceName: "testing-a-very-long-name"
),
workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
workspaceName: "testing-a-very-long-name"
),
workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
@@ -33,7 +31,7 @@ class PreviewVPN: Coder_Desktop.VPNService {
func start() async {
await setState(.connecting)
do {
try await Task.sleep(nanoseconds: 1000000000)
try await Task.sleep(for: .seconds(1))
} catch {
await setState(.failed(.exampleError))
return
@@ -49,7 +47,7 @@ class PreviewVPN: Coder_Desktop.VPNService {
guard state == .connected else { return }
await setState(.disconnecting)
do {
try await Task.sleep(nanoseconds: 1000000000) // Simulate network delay
try await Task.sleep(for: .seconds(1))
} catch {
await setState(.failed(.exampleError))
return
62 changes: 62 additions & 0 deletions Coder Desktop/Coder Desktop/SDK/Client.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Alamofire
import Foundation

protocol Client {
init(url: URL, token: String?)
func user(_ ident: String) async throws -> User
}

struct CoderClient: Client {
public let url: URL
public var token: String?

static let decoder: JSONDecoder = {
var dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds
return dec
}()

let encoder: JSONEncoder = {
var enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601withFractionalSeconds
return enc
}()

func request<T: Encodable>(
_ path: String,
method: HTTPMethod,
body: T
) async -> DataResponse<Data, AFError> {
let url = self.url.appendingPathComponent(path)
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
return await AF.request(
url,
method: method,
parameters: body,
encoder: JSONParameterEncoder.default,
headers: headers
).serializingData().response
}

func request(
_ path: String,
method: HTTPMethod
) async -> DataResponse<Data, AFError> {
let url = self.url.appendingPathComponent(path)
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
return await AF.request(
url,
method: method,
headers: headers
).serializingData().response
}
}

enum ClientError: Error {
case unexpectedStatusCode
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case unexpectedStatusCode
case unexpectedStatusCode(Int)

This lets you store the status code

case badResponse
}

enum Headers {
static let sessionToken = "Coder-Session-Token"
}
30 changes: 30 additions & 0 deletions Coder Desktop/Coder Desktop/SDK/Date.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

// Handling for ISO8601 Timestamps with fractional seconds
// Directly from https://stackoverflow.com/questions/46458487/

extension ParseStrategy where Self == Date.ISO8601FormatStyle {
static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
}

extension JSONDecoder.DateDecodingStrategy {
static let iso8601withOptionalFractionalSeconds = custom {
let string = try $0.singleValueContainer().decode(String.self)
do {
return try .init(string, strategy: .iso8601withFractionalSeconds)
} catch {
return try .init(string, strategy: .iso8601)
}
}
}

extension FormatStyle where Self == Date.ISO8601FormatStyle {
static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
}

extension JSONEncoder.DateEncodingStrategy {
static let iso8601withFractionalSeconds = custom {
var container = $1.singleValueContainer()
try container.encode($0.formatted(.iso8601withFractionalSeconds))
}
}
36 changes: 36 additions & 0 deletions Coder Desktop/Coder Desktop/SDK/User.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

extension CoderClient {
func user(_ ident: String) async throws -> User {
let resp = await request("/api/v2/users/\(ident)", method: .get)
guard let response = resp.response, response.statusCode == 200 else {
throw ClientError.unexpectedStatusCode
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine for now, but we should be parsing the generic error responses for debugging/troubleshooting

e.g. https://github.com/coder/coder/blob/b39becba66f1c6ad9c8d73694b0d8779cf7467a2/codersdk/client.go#L495

}
guard let data = resp.data else {
throw ClientError.badResponse
}
return try CoderClient.decoder.decode(User.self, from: data)
}
}

struct User: Decodable {
let id: UUID
let username: String
let avatar_url: String
let name: String
let email: String
let created_at: Date
let updated_at: Date
let last_seen_at: Date
let status: String
let login_type: String
let theme_preference: String
let organization_ids: [UUID]
let roles: [Role]
}

struct Role: Decodable {
let name: String
let display_name: String
let organization_id: UUID?
}
45 changes: 27 additions & 18 deletions Coder Desktop/Coder Desktop/Session.swift
Original file line number Diff line number Diff line change
@@ -1,64 +1,73 @@
import KeychainAccess
import Foundation
import KeychainAccess

protocol Session: ObservableObject {
var hasSession: Bool { get }
var sessionToken: String? { get }
var baseAccessURL: URL? { get }
var sessionToken: String? { get }

func login(baseAccessURL: URL, sessionToken: String)
func logout()
func store(baseAccessURL: URL, sessionToken: String)
func clear()
}

class SecureSession: ObservableObject {
// Stored in UserDefaults
@Published private(set) var hasSession: Bool {
didSet {
UserDefaults.standard.set(hasSession, forKey: "hasSession")
UserDefaults.standard.set(hasSession, forKey: Keys.hasSession)
}
}
@Published private(set) var sessionToken: String? {

@Published private(set) var baseAccessURL: URL? {
didSet {
setValue(sessionToken, for: "sessionToken")
UserDefaults.standard.set(baseAccessURL, forKey: Keys.baseAccessURL)
}
}
@Published private(set) var baseAccessURL: URL? {

// Stored in Keychain
@Published private(set) var sessionToken: String? {
didSet {
setValue(baseAccessURL?.absoluteString, for: "baseAccessURL")
keychainSet(sessionToken, for: Keys.sessionToken)
}
}

private let keychain: Keychain

public init() {
keychain = Keychain(service: Bundle.main.bundleIdentifier!)
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: "hasSession"))
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: Keys.hasSession))
_baseAccessURL = Published(initialValue: UserDefaults.standard.url(forKey: Keys.baseAccessURL))
if hasSession {
_sessionToken = Published(initialValue: getValue(for: "sessionToken"))
_baseAccessURL = Published(initialValue: getValue(for: "baseAccessURL").flatMap(URL.init))
_sessionToken = Published(initialValue: keychainGet(for: Keys.sessionToken))
}
}

public func login(baseAccessURL: URL, sessionToken: String) {
public func store(baseAccessURL: URL, sessionToken: String) {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
}

// Called when the user logs out, or if we find out the token has expired
public func logout() {
public func clear() {
hasSession = false
sessionToken = nil
baseAccessURL = nil
}

private func getValue(for key: String) -> String? {
private func keychainGet(for key: String) -> String? {
try? keychain.getString(key)
}

private func setValue(_ value: String?, for key: String) {
private func keychainSet(_ value: String?, for key: String) {
if let value = value {
try? keychain.set(value, key: key)
} else {
try? keychain.remove(key)
}
}

enum Keys {
static let hasSession = "hasSession"
static let baseAccessURL = "baseAccessURL"
static let sessionToken = "sessionToken"
}
}
1 change: 1 addition & 0 deletions Coder Desktop/Coder Desktop/Theme.swift
Original file line number Diff line number Diff line change
@@ -8,4 +8,5 @@ enum Theme {

static let rectCornerRadius: CGFloat = 4
}
static let defaultVisibleAgents = 5
}
2 changes: 1 addition & 1 deletion Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ enum VPNServiceState: Equatable {
}

enum VPNServiceError: Error, Equatable {
// TODO:
// TODO:
case exampleError

var description: String {
36 changes: 20 additions & 16 deletions Coder Desktop/Coder Desktop/Views/Agents.swift
Original file line number Diff line number Diff line change
@@ -6,23 +6,27 @@ struct Agents<VPN: VPNService, S: Session>: View {
@State private var viewAll = false
private let defaultVisibleRows = 5

internal let inspection = Inspection<Self>()

var body: some View {
// Workspaces List
if vpn.state == .connected {
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
ForEach(visibleData, id: \.id) { workspace in
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
.padding(.horizontal, Theme.Size.trayMargin)
}
if vpn.agents.count > defaultVisibleRows {
Toggle(isOn: $viewAll) {
Text(viewAll ? "Show Less" : "Show All")
.font(.headline)
.foregroundColor(.gray)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 2)
}.toggleStyle(.button).buttonStyle(.plain)
Group {
// Workspaces List
if vpn.state == .connected {
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
ForEach(visibleData, id: \.id) { workspace in
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
.padding(.horizontal, Theme.Size.trayMargin)
}
if vpn.agents.count > defaultVisibleRows {
Toggle(isOn: $viewAll) {
Text(viewAll ? "Show Less" : "Show All")
.font(.headline)
.foregroundColor(.gray)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 2)
}.toggleStyle(.button).buttonStyle(.plain)
}
}
}
}.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
Copy link
Member Author

@ethanndickson ethanndickson Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I absolutely hate that you need to modify your UI code to get the tests to work, but you do. If nalexn/ViewInspector#231 is ever resolved you'd be able to just wrap the view with the inspector at test-time instead.

}
}
8 changes: 4 additions & 4 deletions Coder Desktop/Coder Desktop/Views/AuthButton.swift
Original file line number Diff line number Diff line change
@@ -3,21 +3,21 @@ import SwiftUI
struct AuthButton<VPN: VPNService, S: Session>: View {
@EnvironmentObject var session: S
@EnvironmentObject var vpn: VPN
@Environment(\.openWindow) var openWindow

var body: some View {
Button {
if session.hasSession {
Task {
await vpn.stop()
session.logout()
session.clear()
}
} else {
// TODO: Login flow
session.login(baseAccessURL: URL(string: "https://dev.coder.com")!, sessionToken: "fake-token")
openWindow(id: .login)
}
} label: {
ButtonRowView {
Text(session.hasSession ? "Logout" : "Login")
Text(session.hasSession ? "Sign Out" : "Sign In")
}
}.buttonStyle(.plain)
}
195 changes: 195 additions & 0 deletions Coder Desktop/Coder Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import SwiftUI

struct LoginForm<C: Client, S: Session>: View {
@EnvironmentObject var session: S
@Environment(\.dismiss) private var dismiss

@State private var baseAccessURL: String = ""
@State private var sessionToken: String = ""
@State private var loginError: LoginError?
@State private var currentPage: LoginPage = .serverURL
@State private var loading: Bool = false
@FocusState private var focusedField: LoginField?

internal let inspection = Inspection<Self>()

var body: some View {
VStack {
VStack {
switch currentPage {
case .serverURL:
serverURLPage
.transition(.move(edge: .leading))
.onAppear {
DispatchQueue.main.async {
focusedField = .baseAccessURL
}
}
case .sessionToken:
sessionTokenPage
.transition(.move(edge: .trailing))
.onAppear {
DispatchQueue.main.async {
focusedField = .sessionToken
}
}
}
}
.animation(.easeInOut, value: currentPage)
.onAppear {
loginError = nil
baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL
sessionToken = ""
}.padding(.top, 35)
VStack(alignment: .center) {
if let loginError {
Text("\(loginError.description)")
.font(.headline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
}
.frame(height: 35)
}.padding()
.frame(width: 450, height: 220)
.disabled(loading)
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}

internal func submit() async {
loginError = nil
guard sessionToken != "" else {
loginError = .invalidToken
return
}
guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
loginError = .invalidURL
return
}
loading = true
defer { loading = false}
let client = C(url: url, token: sessionToken)
do {
_ = try await client.user("me")
} catch {
loginError = .failedAuth
print("Set error")
return
}
session.store(baseAccessURL: url, sessionToken: sessionToken)
dismiss()
}

private var serverURLPage: some View {
VStack(spacing: 15) {
Text("Coder Desktop").font(.title).padding(.bottom, 15)
VStack(alignment: .leading) {
HStack(alignment: .firstTextBaseline) {
Text("Server URL")
Spacer()
TextField("https://coder.example.com", text: $baseAccessURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.disableAutocorrection(true)
.frame(width: 290, alignment: .leading)
.focused($focusedField, equals: .baseAccessURL)
}
}
HStack {
Button("Next", action: next)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
.padding(.top, 10)
}.padding(.horizontal, 15)
}

private var sessionTokenPage: some View {
VStack {
VStack(alignment: .leading) {
HStack(alignment: .firstTextBaseline) {
Text("Server URL")
Spacer()
TextField("https://coder.example.com", text: $baseAccessURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.disableAutocorrection(true)
.frame(width: 290, alignment: .leading)
.disabled(true)
}
HStack(alignment: .firstTextBaseline) {
Text("Session Token")
Spacer()
SecureField("", text: $sessionToken)
.textFieldStyle(RoundedBorderTextFieldStyle())
.disableAutocorrection(true)
.frame(width: 290, alignment: .leading)
.privacySensitive()
.focused($focusedField, equals: .sessionToken)
}
Link(
"Generate a token via the Web UI",
destination: URL(string: baseAccessURL)!.appendingPathComponent("cli-auth")
).font(.callout).foregroundColor(.blue).underline()
}.padding()
HStack {
Button("Back", action: back)
Button("Sign In") {
Task { await submit() }
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}.padding(.top, 5)
}
}

private func next() {
loginError = nil
guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
loginError = .invalidURL
return
}
withAnimation {
currentPage = .sessionToken
focusedField = .sessionToken
}
}

private func back() {
withAnimation {
loginError = nil
currentPage = .serverURL
focusedField = .baseAccessURL
}
}
}

enum LoginError {
case invalidURL
case invalidToken
case failedAuth

var description: String {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidToken:
return "Invalid Session Token"
case .failedAuth:
return "Could not authenticate with Coder deployment"
}
}
}

enum LoginPage {
case serverURL
case sessionToken
}

enum LoginField: Hashable {
case baseAccessURL
case sessionToken
}

#Preview {
LoginForm<PreviewClient, PreviewSession>()
.environmentObject(PreviewSession())
}
13 changes: 13 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Util.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Combine

// This is required for inspecting stateful views
internal final class Inspection<V> {
let notice = PassthroughSubject<UInt, Never>()
var callbacks = [UInt: (V) -> Void]()

func visit(_ view: V, _ line: UInt) {
if let callback = callbacks.removeValue(forKey: line) {
callback(view)
}
}
}
33 changes: 19 additions & 14 deletions Coder Desktop/Coder Desktop/Views/VPNMenu.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import SwiftUI

struct VPNMenu<VPN: VPNService, S: Session>: View {
@ObservedObject var vpn: VPN
@ObservedObject var session: S
@EnvironmentObject var vpn: VPN
@EnvironmentObject var session: S

internal let inspection = Inspection<Self>()

var body: some View {
// Main stack
@@ -13,15 +15,14 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
Toggle(isOn: Binding(
get: { self.vpn.state == .connected || self.vpn.state == .connecting },
set: { isOn in Task {
if isOn { await self.vpn.start() } else { await self.vpn.stop() }
}
if isOn { await self.vpn.start() } else { await self.vpn.stop() }
}
}
)) {
Text("CoderVPN")
.frame(maxWidth: .infinity, alignment: .leading)
}.toggleStyle(.switch)
.disabled(vpnDisabled)
.accessibilityIdentifier("coderVPNToggle")
}
Divider()
Text("Workspace Agents")
@@ -30,7 +31,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
if session.hasSession {
VPNState<VPN>()
} else {
Text("Login to use CoderVPN")
Text("Sign in to use CoderVPN")
.font(.body)
.foregroundColor(.gray)
}
@@ -49,8 +50,12 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
TrayDivider()
}
AuthButton<VPN, S>()
ButtonRowView {
Text("About")
Button {
About.open()
} label: {
ButtonRowView {
Text("About")
}
}.buttonStyle(.plain)
TrayDivider()
Button {
@@ -67,18 +72,18 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
}.padding(.bottom, Theme.Size.trayMargin)
.environmentObject(vpn)
.environmentObject(session)
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}

private var vpnDisabled: Bool {
return !session.hasSession ||
vpn.state == .connecting ||
vpn.state == .disconnecting
vpn.state == .connecting ||
vpn.state == .disconnecting
}
}

#Preview {
VPNMenu(
vpn: PreviewVPN(shouldFail: false),
session: PreviewSession()
).frame(width: 256)
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
.environmentObject(PreviewVPN())
.environmentObject(PreviewSession())
}
51 changes: 28 additions & 23 deletions Coder Desktop/Coder Desktop/Views/VPNState.swift
Original file line number Diff line number Diff line change
@@ -3,31 +3,36 @@ import SwiftUI
struct VPNState<VPN: VPNService>: View {
@EnvironmentObject var vpn: VPN

internal let inspection = Inspection<Self>()

var body: some View {
switch vpn.state {
case .disabled:
Text("Enable CoderVPN to see agents")
.font(.body)
.foregroundColor(.gray)
case .connecting, .disconnecting:
HStack {
Spacer()
ProgressView(
vpn.state == .connecting ? "Starting CoderVPN..." : "Stopping CoderVPN..."
).padding()
Spacer()
Group {
switch vpn.state {
case .disabled:
Text("Enable CoderVPN to see agents")
.font(.body)
.foregroundColor(.gray)
case .connecting, .disconnecting:
HStack {
Spacer()
ProgressView(
vpn.state == .connecting ? "Starting CoderVPN..." : "Stopping CoderVPN..."
).padding()
Spacer()
}
case let .failed(vpnErr):
Text("\(vpnErr.description)")
.font(.headline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.vertical, Theme.Size.trayPadding)
.frame(maxWidth: .infinity)
default:
EmptyView()
}
case let .failed(vpnErr):
Text("\(vpnErr.description)")
.font(.headline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.vertical, Theme.Size.trayPadding)
.frame(maxWidth: .infinity)
default:
EmptyView()
}
.onReceive(inspection.notice) { inspection.visit(self, $0) } // viewInspector
}
}
24 changes: 24 additions & 0 deletions Coder Desktop/Coder Desktop/Windows.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

// Window IDs
enum Windows: String {
case login
}

extension OpenWindowAction {
// Type-safe wrapper for opening windows that also focuses the new window
func callAsFunction(id: Windows) {
#if compiler(>=5.9) && canImport(AppKit)
if #available(macOS 14, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
#else
NSApp.activate(ignoringOtherApps: true)
#endif
callAsFunction(id: id.rawValue)
// The arranging behaviour is flakey without this
NSApp.arrangeInFront(nil)
}
}
82 changes: 62 additions & 20 deletions Coder Desktop/Coder DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
@testable import Coder_Desktop
import Testing
import ViewInspector
import XCTest
import SwiftUI

@Suite(.timeLimit(.minutes(1)))
struct AgentsTests {
let vpn: MockVPNService
let session: MockSession
let sut: Agents<MockVPNService, MockSession>
let view: any View

init() {
vpn = MockVPNService()
session = MockSession()
sut = Agents<MockVPNService, MockSession>()
view = sut.environmentObject(vpn).environmentObject(session)
}

final class AgentsTests: XCTestCase {
private func createMockAgents(count: Int) -> [Agent] {
return (1...count).map {
return (1 ... count).map {
Agent(
id: UUID(),
name: "a\($0)",
@@ -15,34 +29,62 @@ final class AgentsTests: XCTestCase {
}
}

func testAgentsWhenVPNOff() throws {
let vpn = MockVPNService()
@Test
@MainActor
func agentsWhenVPNOff() throws {
vpn.state = .disabled
let session = MockSession()
let view = Agents<MockVPNService, MockSession>().environmentObject(vpn).environmentObject(session)

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

func testAgentsWhenVPNOn() throws {
let vpn = MockVPNService()
@Test
@MainActor
func agentsWhenVPNOn() throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 7)
let session = MockSession()
let view = Agents<MockVPNService, MockSession>().environmentObject(vpn).environmentObject(session)
vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2)

let forEach = try view.inspect().find(ViewType.ForEach.self)
XCTAssertEqual(forEach.count, 5)
let _ = try view.inspect().find(link: "a1.coder")
#expect(forEach.count == Theme.defaultVisibleAgents)
#expect(throws: Never.self) { try view.inspect().find(link: "a1.coder")}
}

@Test
@MainActor
func showAllToggle() async throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 7)

try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
#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)
#expect(forEach.count == Theme.defaultVisibleAgents + 2)
#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(forEach.count == Theme.defaultVisibleAgents)
}
}
}

func testNoToggleWhenAgentsAreFew() throws {
let vpn = MockVPNService()
@Test
@MainActor
func noToggleFewAgents() throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 3)
let session = MockSession()
let view = Agents<MockVPNService, MockSession>().environmentObject(vpn).environmentObject(session)

XCTAssertThrowsError(try view.inspect().find(ViewType.Toggle.self))
#expect(throws: (any Error).self) {
_ = try view.inspect().find(ViewType.Toggle.self)
}
}
}
117 changes: 117 additions & 0 deletions Coder Desktop/Coder DesktopTests/LoginFormTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
@testable import Coder_Desktop
import ViewInspector
import Testing
import SwiftUI

@Suite(.timeLimit(.minutes(1)))
struct LoginTests {
let session: MockSession
let sut: LoginForm<MockClient, MockSession>
let view: any View

init() {
session = MockSession()
sut = LoginForm<MockClient, MockSession>()
view = sut.environmentObject(session)
}

@Test
@MainActor
func testInitialView() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
#expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
#expect(throws: Never.self) { try view.find(text: "Server URL") }
#expect(throws: Never.self) { try view.find(button: "Next") }
}
}
}

@Test
@MainActor
func testInvalidServerURL() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("")
try view.find(button: "Next").tap()
#expect(throws: Never.self) { try view.find(text: "Invalid URL") }
}
}
}

@Test
@MainActor
func testValidServerURL() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
try view.find(button: "Next").tap()

#expect(throws: Never.self) { try view.find(text: "Session Token") }
#expect(throws: Never.self) { try view.find(ViewType.SecureField.self) }
#expect(throws: Never.self) { try view.find(button: "Sign In") }
}
}
}

@Test
@MainActor
func testBackButton() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
try view.find(button: "Next").tap()
try view.find(button: "Back").tap()

#expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
#expect(throws: Never.self) { try view.find(button: "Next") }
}
}
}

@Test
@MainActor
func testInvalidSessionToken() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
try view.find(button: "Next").tap()
try view.find(ViewType.SecureField.self).setInput("")
try await view.actualView().submit()
#expect(throws: Never.self) { try view.find(text: "Invalid Session Token") }
}
}
}

@Test
@MainActor
func testFailedAuthentication() async throws {
let login = LoginForm<MockErrorClient, MockSession>()

try await ViewHosting.host(login.environmentObject(session)) { _ in
try await login.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
try view.find(button: "Next").tap()
#expect(throws: Never.self) { try view.find(text: "Session Token") }
try view.find(ViewType.SecureField.self).setInput("valid-token")
try await view.actualView().submit()
#expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") }
}
}
}

@Test
@MainActor
func testSuccessfulLogin() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
try view.find(button: "Next").tap()
try view.find(ViewType.SecureField.self).setInput("valid-token")
try view.find(button: "Sign In").tap()

#expect(session.hasSession)
}
}
}
}
49 changes: 41 additions & 8 deletions Coder Desktop/Coder DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import SwiftUI
@testable import Coder_Desktop
import Combine
import SwiftUI
import ViewInspector

class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var baseAccessURL: URL = URL(string: "https://dev.coder.com")!
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var agents: [Coder_Desktop.Agent] = []
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?

@MainActor
func start() async {
self.state = .connecting
state = .connecting
await onStart?()
}

@MainActor
func stop() async {
self.state = .disconnecting
state = .disconnecting
await onStop?()
}
}
@@ -29,15 +31,46 @@ class MockSession: Session {
@Published
var baseAccessURL: URL? = URL(string: "https://dev.coder.com")!

func login(baseAccessURL: URL, sessionToken: String) {
func store(baseAccessURL _: URL, sessionToken _: String) {
hasSession = true
self.baseAccessURL = URL(string: "https://dev.coder.com")!
self.sessionToken = "fake-token"
baseAccessURL = URL(string: "https://dev.coder.com")!
sessionToken = "fake-token"
}

func logout() {
func clear() {
hasSession = false
sessionToken = nil
baseAccessURL = nil
}
}

struct MockClient: Client {
init(url _: URL, token _: String? = nil) {}

func user(_: String) async throws -> Coder_Desktop.User {
User(
id: UUID(),
username: "admin",
avatar_url: "",
name: "admin",
email: "admin@coder.com",
created_at: Date.now,
updated_at: Date.now,
last_seen_at: Date.now,
status: "active",
login_type: "none",
theme_preference: "dark",
organization_ids: [],
roles: []
)
}
}

struct MockErrorClient: Client {
init(url: URL, token: String?) {}
func user(_ ident: String) async throws -> Coder_Desktop.User {
throw ClientError.badResponse
}
}

extension Inspection: @retroactive InspectionEmissary { }
195 changes: 107 additions & 88 deletions Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
Original file line number Diff line number Diff line change
@@ -1,107 +1,126 @@
@testable import Coder_Desktop
import Testing
import ViewInspector
import XCTest
import SwiftUI

final class VPNMenuTests: XCTestCase {
func testVPNLoggedOut() throws {
let vpn = MockVPNService()
let session = MockSession()
session.hasSession = false
let view = VPNMenu(vpn: vpn, session: session)
let toggle = try view.inspect().find(ViewType.Toggle.self)
@Suite(.timeLimit(.minutes(1)))
struct VPNMenuTests {
let vpn: MockVPNService
let session: MockSession
let sut: VPNMenu<MockVPNService, MockSession>
let view: any View

XCTAssertTrue(toggle.isDisabled())
XCTAssertNoThrow(try view.inspect().find(text: "Login to use CoderVPN"))
XCTAssertNoThrow(try view.inspect().find(button: "Login"))
init() {
vpn = MockVPNService()
session = MockSession()
sut = VPNMenu<MockVPNService, MockSession>()
view = sut.environmentObject(vpn).environmentObject(session)
}

func testStartStopCalled() throws {
let vpn = MockVPNService()
let session = MockSession()
let view = VPNMenu(vpn: vpn, session: session)
let toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertFalse(try toggle.isOn())

var e = expectation(description: "start is called")
vpn.onStart = {
vpn.state = .connected
e.fulfill()
@Test
@MainActor
func testVPNLoggedOut() async throws {
session.hasSession = false

try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
let toggle = try view.find(ViewType.Toggle.self)
#expect(toggle.isDisabled())
#expect(throws: Never.self) { try view.find(text: "Sign in to use CoderVPN") }
#expect(throws: Never.self) { try view.find(button: "Sign In") }
}
}
try toggle.tap()
wait(for: [e], timeout: 1.0)
XCTAssertTrue(try toggle.isOn())

e = expectation(description: "stop is called")
vpn.onStop = {
vpn.state = .disabled
e.fulfill()
}

@Test
@MainActor
func testStartStopCalled() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
#expect(try !toggle.isOn())

vpn.onStart = {
vpn.state = .connected
}
await vpn.start()

toggle = try view.find(ViewType.Toggle.self)
#expect(try toggle.isOn())

vpn.onStop = {
vpn.state = .disabled
}
await vpn.stop()
#expect(try !toggle.isOn())
}
}
try toggle.tap()
wait(for: [e], timeout: 1.0)
}

func testVPNDisabledWhileConnecting() throws {
let vpn = MockVPNService()
let session = MockSession()
@Test
@MainActor
func testVPNDisabledWhileConnecting() async throws {
vpn.state = .disabled
let view = VPNMenu(vpn: vpn, session: session)
var toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertFalse(try toggle.isOn())

let e = expectation(description: "start is called")
vpn.onStart = {
e.fulfill()

try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
#expect(try !toggle.isOn())

vpn.onStart = {
vpn.state = .connecting
}
await vpn.start()

toggle = try view.find(ViewType.Toggle.self)
#expect(toggle.isDisabled())
}
}
try toggle.tap()
wait(for: [e], timeout: 1.0)

toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertTrue(toggle.isDisabled())
}
func testVPNDisabledWhileDisconnecting() throws {
let vpn = MockVPNService()
let session = MockSession()

@Test
@MainActor
func testVPNDisabledWhileDisconnecting() async throws {
vpn.state = .disabled
let view = VPNMenu(vpn: vpn, session: session)
var toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertFalse(try toggle.isOn())

var e = expectation(description: "start is called")
vpn.onStart = {
e.fulfill()
vpn.state = .connected
}
try toggle.tap()
wait(for: [e], timeout: 1.0)

e = expectation(description: "stop is called")
vpn.onStop = {
e.fulfill()

try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
#expect(try !toggle.isOn())

vpn.onStart = {
vpn.state = .connected
}
await vpn.start()
#expect(try toggle.isOn())

vpn.onStop = {
vpn.state = .disconnecting
}
await vpn.stop()

toggle = try view.find(ViewType.Toggle.self)
#expect(toggle.isDisabled())
}
}
try toggle.tap()
wait(for: [e], timeout: 1.0)

toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertTrue(toggle.isDisabled())
}

func testOffWhenFailed() throws {
let vpn = MockVPNService()
let session = MockSession()
let view = VPNMenu(vpn: vpn, session: session)
let toggle = try view.inspect().find(ViewType.Toggle.self)
XCTAssertFalse(try toggle.isOn())

let e = expectation(description: "toggle is off")
vpn.onStart = {
vpn.state = .failed(.exampleError)
e.fulfill()

@Test
@MainActor
func testOffWhenFailed() async throws {
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
let toggle = try view.find(ViewType.Toggle.self)
#expect(try !toggle.isOn())

vpn.onStart = {
vpn.state = .failed(.exampleError)
}
await vpn.start()

#expect(try !toggle.isOn())
#expect(!toggle.isDisabled())
}
}
try toggle.tap()
wait(for: [e], timeout: 1.0)
XCTAssertFalse(try toggle.isOn())
XCTAssertFalse(toggle.isDisabled())
}

}
86 changes: 61 additions & 25 deletions Coder Desktop/Coder DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
@@ -1,48 +1,84 @@
@testable import Coder_Desktop
import ViewInspector
import XCTest
import Testing
import SwiftUI

final class VPNStateTests: XCTestCase {
@Suite(.timeLimit(.minutes(1)))
struct VPNStateTests {
let vpn: MockVPNService
let sut: VPNState<MockVPNService>
let view: any View

func testDisabledState() throws {
let vpn = MockVPNService()
init() {
vpn = MockVPNService()
sut = VPNState<MockVPNService>()
view = sut.environmentObject(vpn)
}

@Test
@MainActor
func testDisabledState() async throws {
vpn.state = .disabled
let view = VPNState<MockVPNService>().environmentObject(vpn)
_ = try view.inspect().find(text: "Enable CoderVPN to see agents")

try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
#expect(throws: Never.self) {
try view.find(text: "Enable CoderVPN to see agents")
}
}
}
}

func testConnectingState() throws {
let vpn = MockVPNService()
@Test
@MainActor
func testConnectingState() async throws {
vpn.state = .connecting
let view = VPNState<MockVPNService>().environmentObject(vpn)

let progressView = try view.inspect().find(ViewType.ProgressView.self)
XCTAssertEqual(try progressView.labelView().text().string(), "Starting CoderVPN...")
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
let progressView = try view.find(ViewType.ProgressView.self)
#expect(try progressView.labelView().text().string() == "Starting CoderVPN...")
}
}
}

func testDisconnectingState() throws {
let vpn = MockVPNService()
@Test
@MainActor
func testDisconnectingState() async throws {
vpn.state = .disconnecting
let view = VPNState<MockVPNService>().environmentObject(vpn)

let progressView = try view.inspect().find(ViewType.ProgressView.self)
XCTAssertEqual(try progressView.labelView().text().string(), "Stopping CoderVPN...")
try await ViewHosting.host(view) { _ in
try await sut.inspection.inspect { view in
let progressView = try view.find(ViewType.ProgressView.self)
#expect(try progressView.labelView().text().string() == "Stopping CoderVPN...")
}
}
}

func testFailedState() throws {
let vpn = MockVPNService()
@Test
@MainActor
func testFailedState() async throws {
vpn.state = .failed(.exampleError)
let view = VPNState<MockVPNService>().environmentObject(vpn)

let text = try view.inspect().find(ViewType.Text.self)
XCTAssertEqual(try text.string(), VPNServiceError.exampleError.description)
try await ViewHosting.host(view.environmentObject(vpn)) { _ in
try await sut.inspection.inspect { view in
let text = try view.find(ViewType.Text.self)
#expect(try text.string() == VPNServiceError.exampleError.description)
}
}
}

func testDefaultState() throws {
let vpn = MockVPNService()
@Test
@MainActor
func testDefaultState() async throws {
vpn.state = .connected
let view = VPNState<MockVPNService>().environmentObject(vpn)

XCTAssertThrowsError(try view.inspect().find(ViewType.Text.self))
try await ViewHosting.host(view.environmentObject(vpn)) { _ in
try await sut.inspection.inspect { view in
#expect(throws: (any Error).self) {
_ = try view.find(ViewType.Text.self)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import XCTest

final class Coder_DesktopUITests: XCTestCase {

override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.

Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import XCTest

final class Coder_DesktopUITestsLaunchTests: XCTestCase {

override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}