Skip to content

chore: add file sync daemon tests #129

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 9, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -51,9 +51,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
#elseif arch(x86_64)
let mutagenBinary = "mutagen-darwin-amd64"
#endif
fileSyncDaemon = MutagenDaemon(
let fileSyncDaemon = MutagenDaemon(
mutagenPath: Bundle.main.url(forResource: mutagenBinary, withExtension: nil)
)
Task {
await fileSyncDaemon.tryStart()
}
self.fileSyncDaemon = fileSyncDaemon
}

func applicationDidFinishLaunching(_: Notification) {
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ final class PreviewFileSync: FileSyncDaemon {
state = .stopped
}

func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}

func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

Original file line number Diff line number Diff line change
@@ -166,10 +166,6 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
defer { loading = false }
do throws(DaemonError) {
try await fileSync.deleteSessions(ids: [selection!])
if fileSync.sessionState.isEmpty {
// Last session was deleted, stop the daemon
await fileSync.stop()
}
} catch {
actionError = error
}
Original file line number Diff line number Diff line change
@@ -100,9 +100,10 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
try await fileSync.deleteSessions(ids: [existingSession.id])
}
try await fileSync.createSession(
localPath: localPath,
agentHost: remoteHostname,
remotePath: remotePath
arg: .init(
alpha: .init(path: localPath, protocolKind: .local),
beta: .init(path: remotePath, protocolKind: .ssh(host: remoteHostname))
)
)
} catch {
createError = error
4 changes: 2 additions & 2 deletions Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
Original file line number Diff line number Diff line change
@@ -103,8 +103,8 @@ struct FilePickerTests {
try disclosureGroup.expand()

// Disclosure group should expand out to 3 more directories
try #expect(await eventually { @MainActor in
return try view.findAll(ViewType.DisclosureGroup.self).count == 6
#expect(await eventually { @MainActor in
return view.findAll(ViewType.DisclosureGroup.self).count == 6
})
}
}
167 changes: 167 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
@testable import Coder_Desktop
import Foundation
import GRPC
import NIO
import Subprocess
import Testing
import VPNLib
import XCTest

@MainActor
@Suite(.timeLimit(.minutes(1)))
class FileSyncDaemonTests {
let tempDir: URL
let mutagenBinary: URL
let mutagenDataDirectory: URL
let mutagenAlphaDirectory: URL
let mutagenBetaDirectory: URL

// Before each test
init() throws {
tempDir = FileManager.default.makeTempDir()!
#if arch(arm64)
let binaryName = "mutagen-darwin-arm64"
#elseif arch(x86_64)
let binaryName = "mutagen-darwin-amd64"
#endif
mutagenBinary = Bundle.main.url(forResource: binaryName, withExtension: nil)!
mutagenDataDirectory = tempDir.appending(path: "mutagen")
mutagenAlphaDirectory = tempDir.appending(path: "alpha")
try FileManager.default.createDirectory(at: mutagenAlphaDirectory, withIntermediateDirectories: true)
mutagenBetaDirectory = tempDir.appending(path: "beta")
try FileManager.default.createDirectory(at: mutagenBetaDirectory, withIntermediateDirectories: true)
}

// After each test
deinit {
try? FileManager.default.removeItem(at: tempDir)
}

private func statesEqual(_ first: DaemonState, _ second: DaemonState) -> Bool {
switch (first, second) {
case (.stopped, .stopped):
true
case (.running, .running):
true
case (.unavailable, .unavailable):
true
default:
false
}
}

@Test
func fullSync() async throws {
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
#expect(statesEqual(daemon.state, .stopped))
#expect(daemon.sessionState.count == 0)

// The daemon won't start until we create a session
await daemon.tryStart()
#expect(statesEqual(daemon.state, .stopped))
#expect(daemon.sessionState.count == 0)

try await daemon.createSession(
arg: .init(
alpha: .init(
path: mutagenAlphaDirectory.path(),
protocolKind: .local
),
beta: .init(
path: mutagenBetaDirectory.path(),
protocolKind: .local
)
)
)

// Daemon should have started itself
#expect(statesEqual(daemon.state, .running))
#expect(daemon.sessionState.count == 1)

// Write a file to Alpha
let alphaFile = mutagenAlphaDirectory.appendingPathComponent("test.txt")
try "Hello, World!".write(to: alphaFile, atomically: true, encoding: .utf8)
#expect(
await eventually(timeout: .seconds(5), interval: .milliseconds(100)) { @MainActor in
return FileManager.default.fileExists(
atPath: self.mutagenBetaDirectory.appending(path: "test.txt").path()
)
})

try await daemon.deleteSessions(ids: daemon.sessionState.map(\.id))
#expect(daemon.sessionState.count == 0)
// Daemon should have stopped itself once all sessions are deleted
#expect(statesEqual(daemon.state, .stopped))
}

@Test
func autoStopStart() async throws {
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
#expect(statesEqual(daemon.state, .stopped))
#expect(daemon.sessionState.count == 0)

try await daemon.createSession(
arg: .init(
alpha: .init(
path: mutagenAlphaDirectory.path(),
protocolKind: .local
),
beta: .init(
path: mutagenBetaDirectory.path(),
protocolKind: .local
)
)
)

try await daemon.createSession(
arg: .init(
alpha: .init(
path: mutagenAlphaDirectory.path(),
protocolKind: .local
),
beta: .init(
path: mutagenBetaDirectory.path(),
protocolKind: .local
)
)
)

#expect(statesEqual(daemon.state, .running))
#expect(daemon.sessionState.count == 2)

try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
#expect(daemon.sessionState.count == 1)
#expect(statesEqual(daemon.state, .running))

try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
#expect(daemon.sessionState.count == 0)
#expect(statesEqual(daemon.state, .stopped))
}

@Test
func orphaned() async throws {
let daemon1 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
await daemon1.refreshSessions()
try await daemon1.createSession(arg:
.init(
alpha: .init(
path: mutagenAlphaDirectory.path(),
protocolKind: .local
),
beta: .init(
path: mutagenBetaDirectory.path(),
protocolKind: .local
)
)
)
#expect(statesEqual(daemon1.state, .running))
#expect(daemon1.sessionState.count == 1)

let daemon2 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
await daemon2.tryStart()
#expect(statesEqual(daemon2.state, .running))

// Daemon 2 should have killed daemon 1, causing it to fail
#expect(daemon1.state.isFailed)
}
}
28 changes: 18 additions & 10 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -47,7 +47,7 @@ class MockFileSyncDaemon: FileSyncDaemon {
[]
}

func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}

func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

@@ -61,24 +61,32 @@ extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
public func eventually(
timeout: Duration = .milliseconds(500),
interval: Duration = .milliseconds(10),
condition: @escaping () async throws -> Bool
) async throws -> Bool {
condition: @Sendable () async throws -> Bool
) async rethrows -> Bool {
let endTime = ContinuousClock.now.advanced(by: timeout)

var lastError: Error?

while ContinuousClock.now < endTime {
do {
if try await condition() { return true }
lastError = nil
} catch {
lastError = error
try await Task.sleep(for: interval)
}
}

if let lastError {
throw lastError
return try await condition()
}

extension FileManager {
func makeTempDir() -> URL? {
let tempDirectory = FileManager.default.temporaryDirectory
let directoryName = String(Int.random(in: 0 ..< 1_000_000))
let directoryURL = tempDirectory.appendingPathComponent(directoryName)

do {
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
return directoryURL
} catch {
return nil
}
}
return false
}
23 changes: 7 additions & 16 deletions Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ public protocol FileSyncDaemon: ObservableObject {
func tryStart() async
func stop() async
func refreshSessions() async
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError)
func deleteSessions(ids: [String]) async throws(DaemonError)
func pauseSessions(ids: [String]) async throws(DaemonError)
func resumeSessions(ids: [String]) async throws(DaemonError)
@@ -76,21 +76,6 @@ public class MutagenDaemon: FileSyncDaemon {
state = .unavailable
return
}

// If there are sync sessions, the daemon should be running
Task {
do throws(DaemonError) {
try await start()
} catch {
state = .failed(error)
return
}
await refreshSessions()
if sessionState.isEmpty {
logger.info("No sync sessions found on startup, stopping daemon")
await stop()
}
}
}

public func tryStart() async {
@@ -99,6 +84,12 @@ public class MutagenDaemon: FileSyncDaemon {
try await start()
} catch {
state = .failed(error)
return
}
await refreshSessions()
if sessionState.isEmpty {
logger.info("No sync sessions found on startup, stopping daemon")
await stop()
}
}

Loading