Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a456ae4

Browse files
committedApr 8, 2025
chore: add file sync daemon tests
1 parent d0133ce commit a456ae4

File tree

9 files changed

+262
-52
lines changed

9 files changed

+262
-52
lines changed
 

‎Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5151
#elseif arch(x86_64)
5252
let mutagenBinary = "mutagen-darwin-amd64"
5353
#endif
54-
fileSyncDaemon = MutagenDaemon(
54+
let fileSyncDaemon = MutagenDaemon(
5555
mutagenPath: Bundle.main.url(forResource: mutagenBinary, withExtension: nil)
5656
)
57+
Task {
58+
await fileSyncDaemon.tryStart()
59+
}
60+
self.fileSyncDaemon = fileSyncDaemon
5761
}
5862

5963
func applicationDidFinishLaunching(_: Notification) {

‎Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ final class PreviewFileSync: FileSyncDaemon {
2020
state = .stopped
2121
}
2222

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

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

‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,6 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
166166
defer { loading = false }
167167
do throws(DaemonError) {
168168
try await fileSync.deleteSessions(ids: [selection!])
169-
if fileSync.sessionState.isEmpty {
170-
// Last session was deleted, stop the daemon
171-
await fileSync.stop()
172-
}
173169
} catch {
174170
actionError = error
175171
}

‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
100100
try await fileSync.deleteSessions(ids: [existingSession.id])
101101
}
102102
try await fileSync.createSession(
103-
localPath: localPath,
104-
agentHost: remoteHostname,
105-
remotePath: remotePath
103+
arg: .init(
104+
alpha: .init(path: localPath, protocolKind: .local),
105+
beta: .init(path: remotePath, protocolKind: .ssh(host: workspace.primaryHost!))
106+
)
106107
)
107108
} catch {
108109
createError = error
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
@testable import Coder_Desktop
2+
import Foundation
3+
import GRPC
4+
import NIO
5+
import Subprocess
6+
import Testing
7+
import VPNLib
8+
import XCTest
9+
10+
@MainActor
11+
@Suite(.timeLimit(.minutes(1)))
12+
class FileSyncDaemonTests {
13+
let tempDir: URL
14+
let mutagenBinary: URL
15+
let mutagenDataDirectory: URL
16+
let mutagenAlphaDirectory: URL
17+
let mutagenBetaDirectory: URL
18+
19+
init() throws {
20+
tempDir = FileManager.default.makeTempDir()!
21+
#if arch(arm64)
22+
let binaryName = "mutagen-darwin-arm64"
23+
#elseif arch(x86_64)
24+
let binaryName = "mutagen-darwin-amd64"
25+
#endif
26+
mutagenBinary = Bundle.main.url(forResource: binaryName, withExtension: nil)!
27+
mutagenDataDirectory = tempDir.appending(path: "mutagen")
28+
mutagenAlphaDirectory = tempDir.appending(path: "alpha")
29+
try FileManager.default.createDirectory(at: mutagenAlphaDirectory, withIntermediateDirectories: true)
30+
mutagenBetaDirectory = tempDir.appending(path: "beta")
31+
try FileManager.default.createDirectory(at: mutagenBetaDirectory, withIntermediateDirectories: true)
32+
}
33+
34+
deinit {
35+
try? FileManager.default.removeItem(at: tempDir)
36+
}
37+
38+
private func statesEqual(_ first: DaemonState, _ second: DaemonState) -> Bool {
39+
switch (first, second) {
40+
case (.stopped, .stopped):
41+
true
42+
case (.running, .running):
43+
true
44+
case (.unavailable, .unavailable):
45+
true
46+
default:
47+
false
48+
}
49+
}
50+
51+
@Test
52+
func fullSync() async throws {
53+
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
54+
#expect(statesEqual(daemon.state, .stopped))
55+
#expect(daemon.sessionState.count == 0)
56+
57+
// The daemon won't start until we create a session
58+
await daemon.tryStart()
59+
#expect(statesEqual(daemon.state, .stopped))
60+
#expect(daemon.sessionState.count == 0)
61+
62+
try await daemon.createSession(
63+
arg: .init(
64+
alpha: .init(
65+
path: mutagenAlphaDirectory.path(),
66+
protocolKind: .local
67+
),
68+
beta: .init(
69+
path: mutagenBetaDirectory.path(),
70+
protocolKind: .local
71+
)
72+
)
73+
)
74+
75+
// Daemon should have started itself
76+
#expect(statesEqual(daemon.state, .running))
77+
#expect(daemon.sessionState.count == 1)
78+
79+
// Write a file to Alpha
80+
let alphaFile = mutagenAlphaDirectory.appendingPathComponent("test.txt")
81+
try "Hello, World!".write(to: alphaFile, atomically: true, encoding: .utf8)
82+
try #expect(
83+
await eventually(timeout: .seconds(5), interval: .milliseconds(100)) { @MainActor in
84+
return try FileManager.default.fileExists(
85+
atPath: self.mutagenBetaDirectory.appending(path: "test.txt").path()
86+
)
87+
})
88+
89+
try await daemon.deleteSessions(ids: daemon.sessionState.map(\.id))
90+
#expect(daemon.sessionState.count == 0)
91+
// Daemon should have stopped itself once all sessions are deleted
92+
#expect(statesEqual(daemon.state, .stopped))
93+
}
94+
95+
@Test
96+
func autoStopStart() async throws {
97+
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
98+
#expect(statesEqual(daemon.state, .stopped))
99+
#expect(daemon.sessionState.count == 0)
100+
101+
try await daemon.createSession(
102+
arg: .init(
103+
alpha: .init(
104+
path: mutagenAlphaDirectory.path(),
105+
protocolKind: .local
106+
),
107+
beta: .init(
108+
path: mutagenBetaDirectory.path(),
109+
protocolKind: .local
110+
)
111+
)
112+
)
113+
114+
try await daemon.createSession(
115+
arg: .init(
116+
alpha: .init(
117+
path: mutagenAlphaDirectory.path(),
118+
protocolKind: .local
119+
),
120+
beta: .init(
121+
path: mutagenBetaDirectory.path(),
122+
protocolKind: .local
123+
)
124+
)
125+
)
126+
127+
#expect(statesEqual(daemon.state, .running))
128+
#expect(daemon.sessionState.count == 2)
129+
130+
try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
131+
#expect(daemon.sessionState.count == 1)
132+
#expect(statesEqual(daemon.state, .running))
133+
134+
try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
135+
#expect(daemon.sessionState.count == 0)
136+
#expect(statesEqual(daemon.state, .stopped))
137+
}
138+
139+
@Test
140+
func orphaned() async throws {
141+
let daemon1 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
142+
await daemon1.refreshSessions()
143+
try await daemon1.createSession(arg:
144+
.init(
145+
alpha: .init(
146+
path: mutagenAlphaDirectory.path(),
147+
protocolKind: .local
148+
),
149+
beta: .init(
150+
path: mutagenBetaDirectory.path(),
151+
protocolKind: .local
152+
)
153+
)
154+
)
155+
#expect(statesEqual(daemon1.state, .running))
156+
#expect(daemon1.sessionState.count == 1)
157+
158+
let daemon2 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
159+
await daemon2.tryStart()
160+
#expect(statesEqual(daemon2.state, .running))
161+
162+
// Daemon 2 should have killed daemon 1, causing it to fail
163+
#expect(daemon1.state.isFailed)
164+
}
165+
}

‎Coder-Desktop/Coder-DesktopTests/Util.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class MockFileSyncDaemon: FileSyncDaemon {
4747
[]
4848
}
4949

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

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

@@ -82,3 +82,18 @@ public func eventually(
8282
}
8383
return false
8484
}
85+
86+
extension FileManager {
87+
func makeTempDir() -> URL? {
88+
let tempDirectory = FileManager.default.temporaryDirectory
89+
let directoryName = String(Int.random(in: 0 ..< 1_000_000))
90+
let directoryURL = tempDirectory.appendingPathComponent(directoryName)
91+
92+
do {
93+
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
94+
return directoryURL
95+
} catch {
96+
return nil
97+
}
98+
}
99+
}

‎Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public protocol FileSyncDaemon: ObservableObject {
1414
func tryStart() async
1515
func stop() async
1616
func refreshSessions() async
17-
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
17+
func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError)
1818
func deleteSessions(ids: [String]) async throws(DaemonError)
1919
func pauseSessions(ids: [String]) async throws(DaemonError)
2020
func resumeSessions(ids: [String]) async throws(DaemonError)
@@ -76,21 +76,6 @@ public class MutagenDaemon: FileSyncDaemon {
7676
state = .unavailable
7777
return
7878
}
79-
80-
// If there are sync sessions, the daemon should be running
81-
Task {
82-
do throws(DaemonError) {
83-
try await start()
84-
} catch {
85-
state = .failed(error)
86-
return
87-
}
88-
await refreshSessions()
89-
if sessionState.isEmpty {
90-
logger.info("No sync sessions found on startup, stopping daemon")
91-
await stop()
92-
}
93-
}
9479
}
9580

9681
public func tryStart() async {
@@ -99,6 +84,12 @@ public class MutagenDaemon: FileSyncDaemon {
9984
try await start()
10085
} catch {
10186
state = .failed(error)
87+
return
88+
}
89+
await refreshSessions()
90+
if sessionState.isEmpty {
91+
logger.info("No sync sessions found on startup, stopping daemon")
92+
await stop()
10293
}
10394
}
10495

‎Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,7 @@ public extension MutagenDaemon {
1717
sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) }
1818
}
1919

20-
func createSession(
21-
localPath: String,
22-
agentHost: String,
23-
remotePath: String
24-
) async throws(DaemonError) {
20+
func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError) {
2521
if case .stopped = state {
2622
do throws(DaemonError) {
2723
try await start()
@@ -35,15 +31,8 @@ public extension MutagenDaemon {
3531
let req = Synchronization_CreateRequest.with { req in
3632
req.prompter = promptID
3733
req.specification = .with { spec in
38-
spec.alpha = .with { alpha in
39-
alpha.protocol = .local
40-
alpha.path = localPath
41-
}
42-
spec.beta = .with { beta in
43-
beta.protocol = .ssh
44-
beta.host = agentHost
45-
beta.path = remotePath
46-
}
34+
spec.alpha = arg.alpha.mutagenURL
35+
spec.beta = arg.beta.mutagenURL
4736
// TODO: Ingest a config from somewhere
4837
spec.configuration = Synchronization_Configuration()
4938
spec.configurationAlpha = Synchronization_Configuration()
@@ -64,20 +53,26 @@ public extension MutagenDaemon {
6453
func deleteSessions(ids: [String]) async throws(DaemonError) {
6554
// Terminating sessions does not require prompting, according to the
6655
// Mutagen CLI
67-
let (stream, promptID) = try await host(allowPrompts: false)
68-
defer { stream.cancel() }
69-
guard case .running = state else { return }
7056
do {
71-
_ = try await client!.sync.terminate(Synchronization_TerminateRequest.with { req in
72-
req.prompter = promptID
73-
req.selection = .with { selection in
74-
selection.specifications = ids
75-
}
76-
}, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
77-
} catch {
78-
throw .grpcFailure(error)
57+
let (stream, promptID) = try await host(allowPrompts: false)
58+
defer { stream.cancel() }
59+
guard case .running = state else { return }
60+
do {
61+
_ = try await client!.sync.terminate(Synchronization_TerminateRequest.with { req in
62+
req.prompter = promptID
63+
req.selection = .with { selection in
64+
selection.specifications = ids
65+
}
66+
}, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
67+
} catch {
68+
throw .grpcFailure(error)
69+
}
7970
}
8071
await refreshSessions()
72+
if sessionState.isEmpty {
73+
// Last session was deleted, stop the daemon
74+
await stop()
75+
}
8176
}
8277

8378
func pauseSessions(ids: [String]) async throws(DaemonError) {
@@ -135,3 +130,44 @@ public extension MutagenDaemon {
135130
await refreshSessions()
136131
}
137132
}
133+
134+
public struct CreateSyncSessionRequest {
135+
public let alpha: Endpoint
136+
public let beta: Endpoint
137+
138+
public init(alpha: Endpoint, beta: Endpoint) {
139+
self.alpha = alpha
140+
self.beta = beta
141+
}
142+
}
143+
144+
public struct Endpoint {
145+
public let path: String
146+
public let protocolKind: ProtocolKind
147+
148+
public init(path: String, protocolKind: ProtocolKind) {
149+
self.path = path
150+
self.protocolKind = protocolKind
151+
}
152+
153+
public enum ProtocolKind {
154+
case local
155+
case ssh(host: String)
156+
}
157+
158+
var mutagenURL: Url_URL {
159+
switch protocolKind {
160+
case .local:
161+
.with { url in
162+
url.path = path
163+
url.protocol = .local
164+
}
165+
case let .ssh(host):
166+
.with { url in
167+
url.path = path
168+
url.protocol = .ssh
169+
url.host = host
170+
}
171+
}
172+
}
173+
}

‎Coder-Desktop/project.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ targets:
164164
SKIP_INSTALL: NO
165165
LD_RUNPATH_SEARCH_PATHS:
166166
# Load frameworks from the SE bundle.
167-
- "@executable_path/../../Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension/Contents/Frameworks"
167+
- "@executable_path/../../Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension/Contents/Frameworks"
168168
- "@executable_path/../Frameworks"
169169
- "@loader_path/Frameworks"
170170
dependencies:
@@ -192,6 +192,8 @@ targets:
192192
platform: macOS
193193
sources:
194194
- path: Coder-DesktopTests
195+
- path: Resources
196+
buildPhase: resources
195197
settings:
196198
base:
197199
BUNDLE_LOADER: "$(TEST_HOST)"

0 commit comments

Comments
 (0)
Please sign in to comment.