From 6b1e939c8b91a3b90d7904d12e3b6a067f0c5b83 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 11 Dec 2024 17:42:04 +1100 Subject: [PATCH 1/4] feat: add login flow & session management --- Coder Desktop/.swiftlint.yml | 2 + .../Coder Desktop.xcodeproj/project.pbxproj | 19 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +- Coder Desktop/Coder Desktop/About.swift | 47 ++++ .../Coder Desktop/Coder_DesktopApp.swift | 38 +++- .../Preview Content/PreviewClient.swift | 25 +++ .../Preview Content/PreviewSession.swift | 7 +- .../Preview Content/PreviewVPN.swift | 10 +- Coder Desktop/Coder Desktop/SDK/Client.swift | 65 ++++++ Coder Desktop/Coder Desktop/SDK/Date.swift | 30 +++ Coder Desktop/Coder Desktop/SDK/User.swift | 36 ++++ Coder Desktop/Coder Desktop/Session.swift | 45 ++-- Coder Desktop/Coder Desktop/VPNService.swift | 2 +- .../Coder Desktop/Views/AuthButton.swift | 8 +- .../Coder Desktop/Views/LoginForm.swift | 200 ++++++++++++++++++ .../Coder Desktop/Views/VPNMenu.swift | 29 +-- Coder Desktop/Coder Desktop/Windows.swift | 24 +++ .../Coder DesktopTests/AgentsTests.swift | 32 ++- .../Coder DesktopTests/LoginFormTests.swift | 133 ++++++++++++ Coder Desktop/Coder DesktopTests/Util.swift | 76 ++++++- .../Coder DesktopTests/VPNMenuTests.swift | 31 ++- .../Coder DesktopTests/VPNStateTests.swift | 1 - .../Coder_DesktopUITests.swift | 1 - .../Coder_DesktopUITestsLaunchTests.swift | 1 - 24 files changed, 787 insertions(+), 86 deletions(-) create mode 100644 Coder Desktop/Coder Desktop/About.swift create mode 100644 Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift create mode 100644 Coder Desktop/Coder Desktop/SDK/Client.swift create mode 100644 Coder Desktop/Coder Desktop/SDK/Date.swift create mode 100644 Coder Desktop/Coder Desktop/SDK/User.swift create mode 100644 Coder Desktop/Coder Desktop/Views/LoginForm.swift create mode 100644 Coder Desktop/Coder Desktop/Windows.swift create mode 100644 Coder Desktop/Coder DesktopTests/LoginFormTests.swift diff --git a/Coder Desktop/.swiftlint.yml b/Coder Desktop/.swiftlint.yml index d824232..49d9e03 100644 --- a/Coder Desktop/.swiftlint.yml +++ b/Coder Desktop/.swiftlint.yml @@ -3,3 +3,5 @@ disabled_rules: - trailing_comma type_name: allowed_symbols: "_" +identifier_name: + allowed_symbols: "_" diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj index a17d2ff..f65c5a1 100644 --- a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj @@ -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 */; diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1070ac8..3d9b7f4 100644 --- a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "726475d6c2c0355de7a4de72708853eaf53eb295e791efe2cc4b8eb5ce4e9ae8", + "originHash" : "42dc2e0a0e0417a7f4f62b3e875c9559038beef7d2265073dd4fc81f2e11ee13", "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, { "identity" : "fluid-menu-bar-extra", "kind" : "remoteSourceControl", diff --git a/Coder Desktop/Coder Desktop/About.swift b/Coder Desktop/Coder Desktop/About.swift new file mode 100644 index 0000000..7f040a1 --- /dev/null +++ b/Coder Desktop/Coder Desktop/About.swift @@ -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, + ]) + } +} diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index bde4a63..8847c02 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,5 +1,5 @@ -import SwiftUI import FluidMenuBarExtra +import SwiftUI @main struct DesktopApp: App { @@ -10,21 +10,39 @@ struct DesktopApp: App { MenuBarExtra("", isInserted: $hidden) { EmptyView() } + Window("Sign In", id: Windows.login.rawValue) { + LoginForm() + }.environmentObject(appDelegate.session) + .environmentObject(appDelegate.client) + .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 + let client: PreviewClient + + override init() { + // TODO: Replace with real implementations + client = PreviewClient() + 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) { + if session.hasSession { + client.initialise(url: session.baseAccessURL!, token: session.sessionToken) } + menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { + VPNMenu().frame(width: 256) + .environmentObject(self.vpn) + .environmentObject(self.session) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + false } } diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift new file mode 100644 index 0000000..3781dcf --- /dev/null +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift @@ -0,0 +1,25 @@ +import SwiftUI + +class PreviewClient: Client { + required init() {} + func initialise(url _: URL, token _: String?) {} + + 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: [] + ) + } +} diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift index c567207..c4022ff 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift @@ -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 } } diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift index b46194b..2599db4 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift @@ -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 diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift new file mode 100644 index 0000000..fbe24cf --- /dev/null +++ b/Coder Desktop/Coder Desktop/SDK/Client.swift @@ -0,0 +1,65 @@ +import Alamofire +import Foundation + +protocol Client: ObservableObject { + func initialise(url: URL, token: String?) + func user(_ ident: String) async throws -> User +} + +class CoderClient: Client { + public var url: URL! + public var token: String? + + let decoder: JSONDecoder + let encoder: JSONEncoder + + required init() { + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601withFractionalSeconds + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds + } + + func initialise(url: URL, token: String? = nil) { + self.token = token + self.url = url + } + + func request( + _ path: String, + method: HTTPMethod, + body: T + ) async -> DataResponse { + 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 { + 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 + case badResponse +} + +enum Headers { + static let sessionToken = "Coder-Session-Token" +} diff --git a/Coder Desktop/Coder Desktop/SDK/Date.swift b/Coder Desktop/Coder Desktop/SDK/Date.swift new file mode 100644 index 0000000..05d536f --- /dev/null +++ b/Coder Desktop/Coder Desktop/SDK/Date.swift @@ -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)) + } +} diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift new file mode 100644 index 0000000..1d9b935 --- /dev/null +++ b/Coder Desktop/Coder Desktop/SDK/User.swift @@ -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 + } + guard let data = resp.data else { + throw ClientError.badResponse + } + return try 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? +} diff --git a/Coder Desktop/Coder Desktop/Session.swift b/Coder Desktop/Coder Desktop/Session.swift index 95036d7..ec99d3e 100644 --- a/Coder Desktop/Coder Desktop/Session.swift +++ b/Coder Desktop/Coder Desktop/Session.swift @@ -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" + } } diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 8c2c5f3..3f535c7 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -17,7 +17,7 @@ enum VPNServiceState: Equatable { } enum VPNServiceError: Error, Equatable { - // TODO: + // TODO: case exampleError var description: String { diff --git a/Coder Desktop/Coder Desktop/Views/AuthButton.swift b/Coder Desktop/Coder Desktop/Views/AuthButton.swift index 86abf07..cfab088 100644 --- a/Coder Desktop/Coder Desktop/Views/AuthButton.swift +++ b/Coder Desktop/Coder Desktop/Views/AuthButton.swift @@ -3,21 +3,21 @@ import SwiftUI struct AuthButton: 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) } diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift new file mode 100644 index 0000000..5c26416 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct LoginForm: View { + @EnvironmentObject var session: S + @EnvironmentObject var client: C + @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? + + 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 = "" + } + ZStack { + if let loginError { + Text("\(loginError.description)") + .font(.headline) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } else if loading { + ProgressView() + } + } + .frame(height: 30) + }.padding() + .frame(width: 450, height: 220) + .disabled(loading) + } + + 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 { + actionButton(title: "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 { + actionButton(title: "Back", action: back) + actionButton(title: "Sign In", action: signIn) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + }.padding(.top, 5) + } + } + + private func actionButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + } + } + + 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 + } + } + + private func signIn() { + loginError = nil + guard sessionToken != "" else { + loginError = .invalidToken + return + } + guard let url = URL(string: baseAccessURL), url.scheme == "https" else { + loginError = .invalidURL + return + } + loading = true + client.initialise(url: url, token: sessionToken) + Task { + do { + _ = try await client.user("me") + } catch { + loginError = .failedAuth + loading = false + return + } + session.store(baseAccessURL: url, sessionToken: sessionToken) + loading = false + dismiss() + } + } +} + +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().environmentObject(PreviewSession()) +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift index 6e43947..44f6c59 100644 --- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift +++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift @@ -1,8 +1,8 @@ import SwiftUI struct VPNMenu: View { - @ObservedObject var vpn: VPN - @ObservedObject var session: S + @EnvironmentObject var vpn: VPN + @EnvironmentObject var session: S var body: some View { // Main stack @@ -13,8 +13,8 @@ struct VPNMenu: 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") @@ -30,7 +30,7 @@ struct VPNMenu: View { if session.hasSession { VPNState() } else { - Text("Login to use CoderVPN") + Text("Sign in to use CoderVPN") .font(.body) .foregroundColor(.gray) } @@ -49,8 +49,12 @@ struct VPNMenu: View { TrayDivider() } AuthButton() - ButtonRowView { - Text("About") + Button { + About.open() + } label: { + ButtonRowView { + Text("About") + } }.buttonStyle(.plain) TrayDivider() Button { @@ -71,14 +75,13 @@ struct VPNMenu: View { 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().frame(width: 256) + .environmentObject(PreviewVPN()) + .environmentObject(PreviewSession()) } diff --git a/Coder Desktop/Coder Desktop/Windows.swift b/Coder Desktop/Coder Desktop/Windows.swift new file mode 100644 index 0000000..e82680c --- /dev/null +++ b/Coder Desktop/Coder Desktop/Windows.swift @@ -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) + } +} diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index 978ed2f..c4e90e2 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -4,7 +4,7 @@ import XCTest final class AgentsTests: XCTestCase { private func createMockAgents(count: Int) -> [Agent] { - return (1...count).map { + return (1 ... count).map { Agent( id: UUID(), name: "a\($0)", @@ -36,7 +36,35 @@ final class AgentsTests: XCTestCase { let _ = try view.inspect().find(link: "a1.coder") } - func testNoToggleWhenAgentsAreFew() throws { + @MainActor + func testShowAllToggle() throws { + let vpn = MockVPNService() + vpn.state = .connected + vpn.agents = createMockAgents(count: 7) + let session = MockSession() + let view = TestWrapperView(wrapped: Agents() + .environmentObject(vpn) + .environmentObject(session)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + + let toggle = try wrapped.find(ViewType.Toggle.self) + XCTAssertEqual(try toggle.labelView().text().string(), "Show All") + XCTAssertFalse(try toggle.isOn()) + + try toggle.tap() + + let forEach = try wrapped.find(ViewType.ForEach.self) + XCTAssertEqual(forEach.count, 7) + + try toggle.tap() + XCTAssertEqual(try toggle.labelView().text().string(), "Show Less") + XCTAssertEqual(forEach.count, 5) + } + } + + func testNoToggleFewAgents() throws { let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: 3) diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift new file mode 100644 index 0000000..b0d07d0 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -0,0 +1,133 @@ +@testable import Coder_Desktop +import ViewInspector +import XCTest + +final class LoginTests: XCTestCase { + @MainActor + func testInitialView() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + XCTAssertNoThrow(try wrapped.find(text: "Coder Desktop")) + XCTAssertNoThrow(try wrapped.find(ViewType.TextField.self).labelView().text().string(), "Server URL") + XCTAssertNoThrow(try wrapped.find(button: "Next")) + } + } + + @MainActor + func testInvalidServerURL() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + let button = try wrapped.find(button: "Next") + try button.tap() + XCTAssertNoThrow(try wrapped.find(text: "Invalid URL")) + } + } + + @MainActor + func testValidServerURL() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") + try wrapped.find(button: "Next").tap() + + XCTAssertNoThrow(try wrapped.find(text: "Session Token")) + XCTAssertNoThrow(try wrapped.find(ViewType.SecureField.self)) + XCTAssertNoThrow(try wrapped.find(button: "Sign In")) + } + } + + @MainActor + func testBackButton() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") + try wrapped.find(button: "Next").tap() + try wrapped.find(button: "Back").tap() + + XCTAssertNoThrow(try wrapped.find(text: "Coder Desktop")) + XCTAssertNoThrow(try wrapped.find(button: "Next")) + } + } + + @MainActor + func testInvalidSessionToken() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") + try wrapped.find(button: "Next").tap() + try wrapped.find(ViewType.SecureField.self).setInput("") + try wrapped.find(button: "Sign In").tap() + + XCTAssertNoThrow(try wrapped.find(text: "Invalid Session Token")) + } + } + + @MainActor + func testFailedAuthentication() throws { + let session = MockSession() + let client = MockClient() + client.shouldFail = true + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") + try wrapped.find(button: "Next").tap() + try wrapped.find(ViewType.SecureField.self).setInput("valid-token") + try wrapped.find(button: "Sign In").tap() + + XCTAssertNoThrow(try wrapped.find(text: "Could not authenticate with Coder deployment")) + } + } + + @MainActor + func testSuccessfulLogin() throws { + let session = MockSession() + let client = MockClient() + let view = TestWrapperView(wrapped: LoginForm() + .environmentObject(session) + .environmentObject(client)) + + _ = view.inspection.inspect { view in + let wrapped = try view.find(viewWithId: TEST_ID) + try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") + try wrapped.find(button: "Next").tap() + try wrapped.find(ViewType.SecureField.self).setInput("valid-token") + try wrapped.find(button: "Sign In").tap() + + XCTAssertTrue(session.hasSession) + } + } +} diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index 7afba27..4d94cfd 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -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,73 @@ 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 } } + +class MockClient: Client { + var shouldFail: Bool = false + required init() {} + + func initialise(url _: URL, token _: String?) {} + + 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: [] + ) + } +} + +public let TEST_ID = "wrapped" + +// This wrapper allows stateful views to be inspected +struct TestWrapperView: View { + let inspection = Inspection() + var wrapped: Wrapped + + init(wrapped: Wrapped) { + self.wrapped = wrapped + } + + var body: some View { + wrapped + .id(TEST_ID) + .onReceive(inspection.notice) { + self.inspection.visit(self, $0) + } + } +} + +final class Inspection { + let notice = PassthroughSubject() + var callbacks = [UInt: (V) -> Void]() + + func visit(_ view: V, _ line: UInt) { + if let callback = callbacks.removeValue(forKey: line) { + callback(view) + } + } +} + +extension Inspection: InspectionEmissary {} diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift index 1244351..a15be07 100644 --- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -7,18 +7,18 @@ final class VPNMenuTests: XCTestCase { let vpn = MockVPNService() let session = MockSession() session.hasSession = false - let view = VPNMenu(vpn: vpn, session: session) + let view = VPNMenu().environmentObject(vpn).environmentObject(session) let toggle = try view.inspect().find(ViewType.Toggle.self) XCTAssertTrue(toggle.isDisabled()) - XCTAssertNoThrow(try view.inspect().find(text: "Login to use CoderVPN")) - XCTAssertNoThrow(try view.inspect().find(button: "Login")) + XCTAssertNoThrow(try view.inspect().find(text: "Sign in to use CoderVPN")) + XCTAssertNoThrow(try view.inspect().find(button: "Sign In")) } func testStartStopCalled() throws { let vpn = MockVPNService() let session = MockSession() - let view = VPNMenu(vpn: vpn, session: session) + let view = VPNMenu().environmentObject(vpn).environmentObject(session) let toggle = try view.inspect().find(ViewType.Toggle.self) XCTAssertFalse(try toggle.isOn()) @@ -44,29 +44,29 @@ final class VPNMenuTests: XCTestCase { let vpn = MockVPNService() let session = MockSession() vpn.state = .disabled - let view = VPNMenu(vpn: vpn, session: session) + let view = VPNMenu().environmentObject(vpn).environmentObject(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 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() vpn.state = .disabled - let view = VPNMenu(vpn: vpn, session: session) + let view = VPNMenu().environmentObject(vpn).environmentObject(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() @@ -74,25 +74,25 @@ final class VPNMenuTests: XCTestCase { } try toggle.tap() wait(for: [e], timeout: 1.0) - + e = expectation(description: "stop is called") vpn.onStop = { e.fulfill() } 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 view = VPNMenu().environmentObject(vpn).environmentObject(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) @@ -103,5 +103,4 @@ final class VPNMenuTests: XCTestCase { XCTAssertFalse(try toggle.isOn()) XCTAssertFalse(toggle.isDisabled()) } - } diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 0934557..0e7ea77 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -3,7 +3,6 @@ import ViewInspector import XCTest final class VPNStateTests: XCTestCase { - func testDisabledState() throws { let vpn = MockVPNService() vpn.state = .disabled diff --git a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift index cc2b586..7ceceec 100644 --- a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift +++ b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift @@ -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. diff --git a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift index bde06fb..ad6b62c 100644 --- a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift +++ b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift @@ -1,7 +1,6 @@ import XCTest final class Coder_DesktopUITestsLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { true } From 7477b2f0ae6d9b5503034065d4dad70acd352d16 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 11 Dec 2024 18:57:21 +1100 Subject: [PATCH 2/4] centre login form in window --- Coder Desktop/Coder Desktop/Views/LoginForm.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift index 5c26416..dfcd92d 100644 --- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -39,18 +39,18 @@ struct LoginForm: View { loginError = nil baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL sessionToken = "" - } - ZStack { + }.padding(.top, 35) + VStack(alignment: .center) { if let loginError { Text("\(loginError.description)") .font(.headline) .foregroundColor(.red) .multilineTextAlignment(.center) } else if loading { - ProgressView() + ProgressView().controlSize(.small) } } - .frame(height: 30) + .frame(height: 35) }.padding() .frame(width: 450, height: 220) .disabled(loading) @@ -196,5 +196,7 @@ enum LoginField: Hashable { } #Preview { - LoginForm().environmentObject(PreviewSession()) + LoginForm() + .environmentObject(PreviewSession()) + .environmentObject(PreviewClient()) } From 790d048f518d15436d3f2cc2fe5643647f8fbca3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Dec 2024 19:32:05 +1100 Subject: [PATCH 3/4] review p1 + port xctests to swift testing --- .../xcschemes/Coder Desktop.xcscheme | 107 ++++++++++ Coder Desktop/Coder Desktop.xctestplan | 37 ++++ .../Coder Desktop/Coder_DesktopApp.swift | 6 - .../Preview Content/PreviewClient.swift | 5 +- Coder Desktop/Coder Desktop/SDK/Client.swift | 33 ++-- Coder Desktop/Coder Desktop/SDK/User.swift | 2 +- Coder Desktop/Coder Desktop/Theme.swift | 1 + .../Coder Desktop/Views/Agents.swift | 36 ++-- .../Coder Desktop/Views/LoginForm.swift | 69 +++---- Coder Desktop/Coder Desktop/Views/Util.swift | 13 ++ .../Coder Desktop/Views/VPNMenu.swift | 4 +- .../Coder Desktop/Views/VPNState.swift | 51 ++--- .../Coder DesktopTests/AgentsTests.swift | 69 ++++--- .../Coder DesktopTests/LoginFormTests.swift | 182 +++++++++--------- Coder Desktop/Coder DesktopTests/Util.swift | 41 +--- .../Coder DesktopTests/VPNMenuTests.swift | 170 +++++++++------- .../Coder DesktopTests/VPNStateTests.swift | 76 ++++++-- 17 files changed, 548 insertions(+), 354 deletions(-) create mode 100644 Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme create mode 100644 Coder Desktop/Coder Desktop.xctestplan create mode 100644 Coder Desktop/Coder Desktop/Views/Util.swift diff --git a/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme b/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme new file mode 100644 index 0000000..004582c --- /dev/null +++ b/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Coder Desktop/Coder Desktop.xctestplan b/Coder Desktop/Coder Desktop.xctestplan new file mode 100644 index 0000000..ab80bb8 --- /dev/null +++ b/Coder Desktop/Coder Desktop.xctestplan @@ -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 +} diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 8847c02..26e5ab4 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -13,7 +13,6 @@ struct DesktopApp: App { Window("Sign In", id: Windows.login.rawValue) { LoginForm() }.environmentObject(appDelegate.session) - .environmentObject(appDelegate.client) .windowResizability(.contentSize) } } @@ -22,19 +21,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var menuBarExtra: FluidMenuBarExtra? let vpn: PreviewVPN let session: PreviewSession - let client: PreviewClient override init() { // TODO: Replace with real implementations - client = PreviewClient() vpn = PreviewVPN() session = PreviewSession() } func applicationDidFinishLaunching(_: Notification) { - if session.hasSession { - client.initialise(url: session.baseAccessURL!, token: session.sessionToken) - } menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { VPNMenu().frame(width: 256) .environmentObject(self.vpn) diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift index 3781dcf..e21e8c6 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift @@ -1,8 +1,7 @@ import SwiftUI -class PreviewClient: Client { - required init() {} - func initialise(url _: URL, token _: String?) {} +struct PreviewClient: Client { + init(url _: URL, token _: String? = nil) {} func user(_: String) async throws -> User { try await Task.sleep(for: .seconds(1)) diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift index fbe24cf..0e32e91 100644 --- a/Coder Desktop/Coder Desktop/SDK/Client.swift +++ b/Coder Desktop/Coder Desktop/SDK/Client.swift @@ -1,29 +1,26 @@ import Alamofire import Foundation -protocol Client: ObservableObject { - func initialise(url: URL, token: String?) +protocol Client { + init(url: URL, token: String?) func user(_ ident: String) async throws -> User } -class CoderClient: Client { - public var url: URL! +struct CoderClient: Client { + public let url: URL public var token: String? - let decoder: JSONDecoder - let encoder: JSONEncoder - - required init() { - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601withFractionalSeconds - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds - } - - func initialise(url: URL, token: String? = nil) { - self.token = token - self.url = url - } + 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( _ path: String, diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift index 1d9b935..4ca26eb 100644 --- a/Coder Desktop/Coder Desktop/SDK/User.swift +++ b/Coder Desktop/Coder Desktop/SDK/User.swift @@ -9,7 +9,7 @@ extension CoderClient { guard let data = resp.data else { throw ClientError.badResponse } - return try decoder.decode(User.self, from: data) + return try CoderClient.decoder.decode(User.self, from: data) } } diff --git a/Coder Desktop/Coder Desktop/Theme.swift b/Coder Desktop/Coder Desktop/Theme.swift index b44a610..1303a4c 100644 --- a/Coder Desktop/Coder Desktop/Theme.swift +++ b/Coder Desktop/Coder Desktop/Theme.swift @@ -8,4 +8,5 @@ enum Theme { static let rectCornerRadius: CGFloat = 4 } + static let defaultVisibleAgents = 5 } diff --git a/Coder Desktop/Coder Desktop/Views/Agents.swift b/Coder Desktop/Coder Desktop/Views/Agents.swift index e11804d..79c402f 100644 --- a/Coder Desktop/Coder Desktop/Views/Agents.swift +++ b/Coder Desktop/Coder Desktop/Views/Agents.swift @@ -6,23 +6,27 @@ struct Agents: View { @State private var viewAll = false private let defaultVisibleRows = 5 + internal let inspection = Inspection() + 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 } } diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift index dfcd92d..d0c0ca6 100644 --- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -2,7 +2,6 @@ import SwiftUI struct LoginForm: View { @EnvironmentObject var session: S - @EnvironmentObject var client: C @Environment(\.dismiss) private var dismiss @State private var baseAccessURL: String = "" @@ -12,6 +11,8 @@ struct LoginForm: View { @State private var loading: Bool = false @FocusState private var focusedField: LoginField? + internal let inspection = Inspection() + var body: some View { VStack { VStack { @@ -54,6 +55,31 @@ struct LoginForm: View { }.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 { @@ -71,7 +97,7 @@ struct LoginForm: View { } } HStack { - actionButton(title: "Next", action: next) + Button("Next", action: next) .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } @@ -107,20 +133,16 @@ struct LoginForm: View { ).font(.callout).foregroundColor(.blue).underline() }.padding() HStack { - actionButton(title: "Back", action: back) - actionButton(title: "Sign In", action: signIn) + Button("Back", action: back) + Button("Sign In") { + Task { await submit() } + } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) }.padding(.top, 5) } } - private func actionButton(title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(title) - } - } - private func next() { loginError = nil guard let url = URL(string: baseAccessURL), url.scheme == "https" else { @@ -140,32 +162,6 @@ struct LoginForm: View { focusedField = .baseAccessURL } } - - private func signIn() { - loginError = nil - guard sessionToken != "" else { - loginError = .invalidToken - return - } - guard let url = URL(string: baseAccessURL), url.scheme == "https" else { - loginError = .invalidURL - return - } - loading = true - client.initialise(url: url, token: sessionToken) - Task { - do { - _ = try await client.user("me") - } catch { - loginError = .failedAuth - loading = false - return - } - session.store(baseAccessURL: url, sessionToken: sessionToken) - loading = false - dismiss() - } - } } enum LoginError { @@ -198,5 +194,4 @@ enum LoginField: Hashable { #Preview { LoginForm() .environmentObject(PreviewSession()) - .environmentObject(PreviewClient()) } diff --git a/Coder Desktop/Coder Desktop/Views/Util.swift b/Coder Desktop/Coder Desktop/Views/Util.swift new file mode 100644 index 0000000..8f42891 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/Util.swift @@ -0,0 +1,13 @@ +import Combine + +// This is required for inspecting stateful views +internal final class Inspection { + let notice = PassthroughSubject() + var callbacks = [UInt: (V) -> Void]() + + func visit(_ view: V, _ line: UInt) { + if let callback = callbacks.removeValue(forKey: line) { + callback(view) + } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift index 44f6c59..814c828 100644 --- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift +++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift @@ -4,6 +4,8 @@ struct VPNMenu: View { @EnvironmentObject var vpn: VPN @EnvironmentObject var session: S + internal let inspection = Inspection() + var body: some View { // Main stack VStackLayout(alignment: .leading) { @@ -21,7 +23,6 @@ struct VPNMenu: View { .frame(maxWidth: .infinity, alignment: .leading) }.toggleStyle(.switch) .disabled(vpnDisabled) - .accessibilityIdentifier("coderVPNToggle") } Divider() Text("Workspace Agents") @@ -71,6 +72,7 @@ struct VPNMenu: View { }.padding(.bottom, Theme.Size.trayMargin) .environmentObject(vpn) .environmentObject(session) + .onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector } private var vpnDisabled: Bool { diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift index b1e5466..05f1c8b 100644 --- a/Coder Desktop/Coder Desktop/Views/VPNState.swift +++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift @@ -3,31 +3,36 @@ import SwiftUI struct VPNState: View { @EnvironmentObject var vpn: VPN + internal let inspection = Inspection() + 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 } } diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index c4e90e2..dfd0f6b 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -1,8 +1,11 @@ @testable import Coder_Desktop +import Testing import ViewInspector -import XCTest +import Foundation -final class AgentsTests: XCTestCase { +@Suite(.timeLimit(.minutes(1))) +struct AgentsTests { + @MainActor private func createMockAgents(count: Int) -> [Agent] { return (1 ... count).map { Agent( @@ -15,62 +18,74 @@ final class AgentsTests: XCTestCase { } } - func testAgentsWhenVPNOff() throws { + @Test + @MainActor + func agentsWhenVPNOff() throws { let vpn = MockVPNService() vpn.state = .disabled let session = MockSession() let view = Agents().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 { + @Test + @MainActor + func agentsWhenVPNOn() throws { let vpn = MockVPNService() vpn.state = .connected - vpn.agents = createMockAgents(count: 7) + vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2) let session = MockSession() let view = Agents().environmentObject(vpn).environmentObject(session) 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 testShowAllToggle() throws { + func showAllToggle() async throws { let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: 7) let session = MockSession() - let view = TestWrapperView(wrapped: Agents() - .environmentObject(vpn) - .environmentObject(session)) + let view = Agents() - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.inspection.inspect { view in + var toggle = try view.find(ViewType.Toggle.self) + #expect(try toggle.labelView().text().string() == "Show All") + #expect(try !toggle.isOn()) - let toggle = try wrapped.find(ViewType.Toggle.self) - XCTAssertEqual(try toggle.labelView().text().string(), "Show All") - XCTAssertFalse(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() - - let forEach = try wrapped.find(ViewType.ForEach.self) - XCTAssertEqual(forEach.count, 7) - - try toggle.tap() - XCTAssertEqual(try toggle.labelView().text().string(), "Show Less") - XCTAssertEqual(forEach.count, 5) + 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 testNoToggleFewAgents() throws { + @Test + @MainActor + func noToggleFewAgents() throws { let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: 3) let session = MockSession() let view = Agents().environmentObject(vpn).environmentObject(session) - XCTAssertThrowsError(try view.inspect().find(ViewType.Toggle.self)) + #expect(throws: (any Error).self) { + _ = try view.inspect().find(ViewType.Toggle.self) + } } } diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift index b0d07d0..f5f9219 100644 --- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -1,133 +1,125 @@ @testable import Coder_Desktop import ViewInspector -import XCTest +import Testing -final class LoginTests: XCTestCase { +@Suite(.timeLimit(.minutes(1))) +struct LoginTests { + @Test @MainActor - func testInitialView() throws { + func testInitialView() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - XCTAssertNoThrow(try wrapped.find(text: "Coder Desktop")) - XCTAssertNoThrow(try wrapped.find(ViewType.TextField.self).labelView().text().string(), "Server URL") - XCTAssertNoThrow(try wrapped.find(button: "Next")) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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() throws { + func testInvalidServerURL() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - let button = try wrapped.find(button: "Next") - try button.tap() - XCTAssertNoThrow(try wrapped.find(text: "Invalid URL")) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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() throws { + func testValidServerURL() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") - try wrapped.find(button: "Next").tap() - - XCTAssertNoThrow(try wrapped.find(text: "Session Token")) - XCTAssertNoThrow(try wrapped.find(ViewType.SecureField.self)) - XCTAssertNoThrow(try wrapped.find(button: "Sign In")) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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() throws { + func testBackButton() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") - try wrapped.find(button: "Next").tap() - try wrapped.find(button: "Back").tap() - - XCTAssertNoThrow(try wrapped.find(text: "Coder Desktop")) - XCTAssertNoThrow(try wrapped.find(button: "Next")) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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() throws { + func testInvalidSessionToken() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") - try wrapped.find(button: "Next").tap() - try wrapped.find(ViewType.SecureField.self).setInput("") - try wrapped.find(button: "Sign In").tap() - - XCTAssertNoThrow(try wrapped.find(text: "Invalid Session Token")) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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() throws { + func testFailedAuthentication() async throws { let session = MockSession() - let client = MockClient() - client.shouldFail = true - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") - try wrapped.find(button: "Next").tap() - try wrapped.find(ViewType.SecureField.self).setInput("valid-token") - try wrapped.find(button: "Sign In").tap() - - XCTAssertNoThrow(try wrapped.find(text: "Could not authenticate with Coder deployment")) + let login = LoginForm() + + 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() throws { + func testSuccessfulLogin() async throws { let session = MockSession() - let client = MockClient() - let view = TestWrapperView(wrapped: LoginForm() - .environmentObject(session) - .environmentObject(client)) - - _ = view.inspection.inspect { view in - let wrapped = try view.find(viewWithId: TEST_ID) - try wrapped.find(ViewType.TextField.self).setInput("https://coder.example.com") - try wrapped.find(button: "Next").tap() - try wrapped.find(ViewType.SecureField.self).setInput("valid-token") - try wrapped.find(button: "Sign In").tap() - - XCTAssertTrue(session.hasSession) + let view = LoginForm() + + try await ViewHosting.host(view.environmentObject(session)) { _ in + try await view.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) + } } } } diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index 4d94cfd..cab9a8f 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -44,11 +44,8 @@ class MockSession: Session { } } -class MockClient: Client { - var shouldFail: Bool = false - required init() {} - - func initialise(url _: URL, token _: String?) {} +struct MockClient: Client { + init(url _: URL, token _: String? = nil) {} func user(_: String) async throws -> Coder_Desktop.User { User( @@ -69,35 +66,11 @@ class MockClient: Client { } } -public let TEST_ID = "wrapped" - -// This wrapper allows stateful views to be inspected -struct TestWrapperView: View { - let inspection = Inspection() - var wrapped: Wrapped - - init(wrapped: Wrapped) { - self.wrapped = wrapped - } - - var body: some View { - wrapped - .id(TEST_ID) - .onReceive(inspection.notice) { - self.inspection.visit(self, $0) - } - } -} - -final class Inspection { - let notice = PassthroughSubject() - var callbacks = [UInt: (V) -> Void]() - - func visit(_ view: V, _ line: UInt) { - if let callback = callbacks.removeValue(forKey: line) { - callback(view) - } +struct MockErrorClient: Client { + init(url: URL, token: String?) {} + func user(_ ident: String) async throws -> Coder_Desktop.User { + throw ClientError.badResponse } } -extension Inspection: InspectionEmissary {} +extension Inspection: @retroactive InspectionEmissary { } diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift index a15be07..7195f93 100644 --- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -1,106 +1,130 @@ @testable import Coder_Desktop +import Testing import ViewInspector -import XCTest -final class VPNMenuTests: XCTestCase { - func testVPNLoggedOut() throws { +@Suite(.timeLimit(.minutes(1))) +struct VPNMenuTests { + @Test + @MainActor + func testVPNLoggedOut() async throws { let vpn = MockVPNService() let session = MockSession() session.hasSession = false - let view = VPNMenu().environmentObject(vpn).environmentObject(session) - let toggle = try view.inspect().find(ViewType.Toggle.self) + let view = VPNMenu() - XCTAssertTrue(toggle.isDisabled()) - XCTAssertNoThrow(try view.inspect().find(text: "Sign in to use CoderVPN")) - XCTAssertNoThrow(try view.inspect().find(button: "Sign In")) + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.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") } + } + } } - func testStartStopCalled() throws { + @Test + @MainActor + func testStartStopCalled() async throws { let vpn = MockVPNService() let session = MockSession() - let view = VPNMenu().environmentObject(vpn).environmentObject(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() - } - 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() + let view = VPNMenu() + + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.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 { + @Test + @MainActor + func testVPNDisabledWhileConnecting() async throws { let vpn = MockVPNService() let session = MockSession() vpn.state = .disabled - let view = VPNMenu().environmentObject(vpn).environmentObject(session) - var toggle = try view.inspect().find(ViewType.Toggle.self) - XCTAssertFalse(try toggle.isOn()) + let view = VPNMenu() - let e = expectation(description: "start is called") - vpn.onStart = { - e.fulfill() - } - try toggle.tap() - wait(for: [e], timeout: 1.0) + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.inspection.inspect { view in + var toggle = try view.find(ViewType.Toggle.self) + #expect(try !toggle.isOn()) - toggle = try view.inspect().find(ViewType.Toggle.self) - XCTAssertTrue(toggle.isDisabled()) + vpn.onStart = { + vpn.state = .connecting + } + await vpn.start() + + toggle = try view.find(ViewType.Toggle.self) + #expect(toggle.isDisabled()) + } + } } - func testVPNDisabledWhileDisconnecting() throws { + @Test + @MainActor + func testVPNDisabledWhileDisconnecting() async throws { let vpn = MockVPNService() let session = MockSession() vpn.state = .disabled - let view = VPNMenu().environmentObject(vpn).environmentObject(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) + let view = VPNMenu() - e = expectation(description: "stop is called") - vpn.onStop = { - e.fulfill() - } - try toggle.tap() - wait(for: [e], timeout: 1.0) + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.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.inspect().find(ViewType.Toggle.self) - XCTAssertTrue(toggle.isDisabled()) + toggle = try view.find(ViewType.Toggle.self) + #expect(toggle.isDisabled()) + } + } } - func testOffWhenFailed() throws { + @Test + @MainActor + func testOffWhenFailed() async throws { let vpn = MockVPNService() let session = MockSession() - let view = VPNMenu().environmentObject(vpn).environmentObject(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() + let view = VPNMenu() + + try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in + try await view.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()) } } diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 0e7ea77..98963af 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -1,47 +1,83 @@ @testable import Coder_Desktop import ViewInspector -import XCTest +import Testing -final class VPNStateTests: XCTestCase { - func testDisabledState() throws { +@Suite(.timeLimit(.minutes(1))) +struct VPNStateTests { + @Test + @MainActor + func testDisabledState() async throws { let vpn = MockVPNService() vpn.state = .disabled - let view = VPNState().environmentObject(vpn) - _ = try view.inspect().find(text: "Enable CoderVPN to see agents") + let view = VPNState() + + try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await view.inspection.inspect { view in + #expect(throws: Never.self) { + try view.find(text: "Enable CoderVPN to see agents") + } + } + } } - func testConnectingState() throws { + @Test + @MainActor + func testConnectingState() async throws { let vpn = MockVPNService() vpn.state = .connecting - let view = VPNState().environmentObject(vpn) + let view = VPNState() - let progressView = try view.inspect().find(ViewType.ProgressView.self) - XCTAssertEqual(try progressView.labelView().text().string(), "Starting CoderVPN...") + try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await view.inspection.inspect { view in + let progressView = try view.find(ViewType.ProgressView.self) + #expect(try progressView.labelView().text().string() == "Starting CoderVPN...") + } + } } - func testDisconnectingState() throws { + @Test + @MainActor + func testDisconnectingState() async throws { let vpn = MockVPNService() vpn.state = .disconnecting - let view = VPNState().environmentObject(vpn) + let view = VPNState() - let progressView = try view.inspect().find(ViewType.ProgressView.self) - XCTAssertEqual(try progressView.labelView().text().string(), "Stopping CoderVPN...") + try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await view.inspection.inspect { view in + let progressView = try view.find(ViewType.ProgressView.self) + #expect(try progressView.labelView().text().string() == "Stopping CoderVPN...") + } + } } - func testFailedState() throws { + @Test + @MainActor + func testFailedState() async throws { let vpn = MockVPNService() vpn.state = .failed(.exampleError) - let view = VPNState().environmentObject(vpn) + let view = VPNState() - 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 view.inspection.inspect { view in + let text = try view.find(ViewType.Text.self) + #expect(try text.string() == VPNServiceError.exampleError.description) + } + } } - func testDefaultState() throws { + @Test + @MainActor + func testDefaultState() async throws { let vpn = MockVPNService() vpn.state = .connected - let view = VPNState().environmentObject(vpn) + let view = VPNState() - XCTAssertThrowsError(try view.inspect().find(ViewType.Text.self)) + try await ViewHosting.host(view.environmentObject(vpn)) { _ in + try await view.inspection.inspect { view in + #expect(throws: (any Error).self) { + _ = try view.find(ViewType.Text.self) + } + } + } } } From d5d7cd8499e3b070c536db36607a2ed7703945f5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Dec 2024 01:16:30 +1100 Subject: [PATCH 4/4] review --- .../Coder Desktop/Views/LoginForm.swift | 2 - .../Coder DesktopTests/AgentsTests.swift | 31 ++++++----- .../Coder DesktopTests/LoginFormTests.swift | 54 ++++++++----------- .../Coder DesktopTests/VPNMenuTests.swift | 50 ++++++++--------- .../Coder DesktopTests/VPNStateTests.swift | 37 ++++++------- 5 files changed, 80 insertions(+), 94 deletions(-) diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift index d0c0ca6..2888d0b 100644 --- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -47,8 +47,6 @@ struct LoginForm: View { .font(.headline) .foregroundColor(.red) .multilineTextAlignment(.center) - } else if loading { - ProgressView().controlSize(.small) } } .frame(height: 35) diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index dfd0f6b..e6c679a 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -1,11 +1,22 @@ @testable import Coder_Desktop import Testing import ViewInspector -import Foundation +import SwiftUI @Suite(.timeLimit(.minutes(1))) struct AgentsTests { - @MainActor + let vpn: MockVPNService + let session: MockSession + let sut: Agents + let view: any View + + init() { + vpn = MockVPNService() + session = MockSession() + sut = Agents() + view = sut.environmentObject(vpn).environmentObject(session) + } + private func createMockAgents(count: Int) -> [Agent] { return (1 ... count).map { Agent( @@ -21,10 +32,7 @@ struct AgentsTests { @Test @MainActor func agentsWhenVPNOff() throws { - let vpn = MockVPNService() vpn.state = .disabled - let session = MockSession() - let view = Agents().environmentObject(vpn).environmentObject(session) #expect(throws: (any Error).self) { _ = try view.inspect().find(ViewType.ForEach.self) @@ -34,11 +42,8 @@ struct AgentsTests { @Test @MainActor func agentsWhenVPNOn() throws { - let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2) - let session = MockSession() - let view = Agents().environmentObject(vpn).environmentObject(session) let forEach = try view.inspect().find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents) @@ -48,14 +53,11 @@ struct AgentsTests { @Test @MainActor func showAllToggle() async throws { - let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: 7) - let session = MockSession() - let view = Agents() - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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()) @@ -78,11 +80,8 @@ struct AgentsTests { @Test @MainActor func noToggleFewAgents() throws { - let vpn = MockVPNService() vpn.state = .connected vpn.agents = createMockAgents(count: 3) - let session = MockSession() - let view = Agents().environmentObject(vpn).environmentObject(session) #expect(throws: (any Error).self) { _ = try view.inspect().find(ViewType.Toggle.self) diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift index f5f9219..3f3b547 100644 --- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -1,17 +1,25 @@ @testable import Coder_Desktop import ViewInspector import Testing +import SwiftUI @Suite(.timeLimit(.minutes(1))) struct LoginTests { + let session: MockSession + let sut: LoginForm + let view: any View + + init() { + session = MockSession() + sut = LoginForm() + view = sut.environmentObject(session) + } + @Test @MainActor func testInitialView() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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") } @@ -22,11 +30,8 @@ struct LoginTests { @Test @MainActor func testInvalidServerURL() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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") } @@ -37,11 +42,8 @@ struct LoginTests { @Test @MainActor func testValidServerURL() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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() @@ -55,11 +57,8 @@ struct LoginTests { @Test @MainActor func testBackButton() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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() @@ -73,11 +72,8 @@ struct LoginTests { @Test @MainActor func testInvalidSessionToken() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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("") @@ -90,7 +86,6 @@ struct LoginTests { @Test @MainActor func testFailedAuthentication() async throws { - let session = MockSession() let login = LoginForm() try await ViewHosting.host(login.environmentObject(session)) { _ in @@ -108,11 +103,8 @@ struct LoginTests { @Test @MainActor func testSuccessfulLogin() async throws { - let session = MockSession() - let view = LoginForm() - - try await ViewHosting.host(view.environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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") diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift index 7195f93..f7d482c 100644 --- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -1,19 +1,29 @@ @testable import Coder_Desktop import Testing import ViewInspector +import SwiftUI @Suite(.timeLimit(.minutes(1))) struct VPNMenuTests { + let vpn: MockVPNService + let session: MockSession + let sut: VPNMenu + let view: any View + + init() { + vpn = MockVPNService() + session = MockSession() + sut = VPNMenu() + view = sut.environmentObject(vpn).environmentObject(session) + } + @Test @MainActor func testVPNLoggedOut() async throws { - let vpn = MockVPNService() - let session = MockSession() session.hasSession = false - let view = VPNMenu() - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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") } @@ -25,12 +35,8 @@ struct VPNMenuTests { @Test @MainActor func testStartStopCalled() async throws { - let vpn = MockVPNService() - let session = MockSession() - let view = VPNMenu() - - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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()) @@ -54,13 +60,10 @@ struct VPNMenuTests { @Test @MainActor func testVPNDisabledWhileConnecting() async throws { - let vpn = MockVPNService() - let session = MockSession() vpn.state = .disabled - let view = VPNMenu() - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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()) @@ -78,13 +81,10 @@ struct VPNMenuTests { @Test @MainActor func testVPNDisabledWhileDisconnecting() async throws { - let vpn = MockVPNService() - let session = MockSession() vpn.state = .disabled - let view = VPNMenu() - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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()) @@ -108,12 +108,8 @@ struct VPNMenuTests { @Test @MainActor func testOffWhenFailed() async throws { - let vpn = MockVPNService() - let session = MockSession() - let view = VPNMenu() - - try await ViewHosting.host(view.environmentObject(vpn).environmentObject(session)) { _ in - try await view.inspection.inspect { view in + 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()) diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 98963af..5a2c960 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -1,18 +1,27 @@ @testable import Coder_Desktop import ViewInspector import Testing +import SwiftUI @Suite(.timeLimit(.minutes(1))) struct VPNStateTests { + let vpn: MockVPNService + let sut: VPNState + let view: any View + + init() { + vpn = MockVPNService() + sut = VPNState() + view = sut.environmentObject(vpn) + } + @Test @MainActor func testDisabledState() async throws { - let vpn = MockVPNService() vpn.state = .disabled - let view = VPNState() - try await ViewHosting.host(view.environmentObject(vpn)) { _ in - try await view.inspection.inspect { view in + 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") } @@ -23,12 +32,10 @@ struct VPNStateTests { @Test @MainActor func testConnectingState() async throws { - let vpn = MockVPNService() vpn.state = .connecting - let view = VPNState() - try await ViewHosting.host(view.environmentObject(vpn)) { _ in - try await view.inspection.inspect { view in + 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...") } @@ -38,12 +45,10 @@ struct VPNStateTests { @Test @MainActor func testDisconnectingState() async throws { - let vpn = MockVPNService() vpn.state = .disconnecting - let view = VPNState() - try await ViewHosting.host(view.environmentObject(vpn)) { _ in - try await view.inspection.inspect { view in + 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...") } @@ -53,12 +58,10 @@ struct VPNStateTests { @Test @MainActor func testFailedState() async throws { - let vpn = MockVPNService() vpn.state = .failed(.exampleError) - let view = VPNState() try await ViewHosting.host(view.environmentObject(vpn)) { _ in - try await view.inspection.inspect { view in + try await sut.inspection.inspect { view in let text = try view.find(ViewType.Text.self) #expect(try text.string() == VPNServiceError.exampleError.description) } @@ -68,12 +71,10 @@ struct VPNStateTests { @Test @MainActor func testDefaultState() async throws { - let vpn = MockVPNService() vpn.state = .connected - let view = VPNState() try await ViewHosting.host(view.environmentObject(vpn)) { _ in - try await view.inspection.inspect { view in + try await sut.inspection.inspect { view in #expect(throws: (any Error).self) { _ = try view.find(ViewType.Text.self) }