Skip to content

Commit 8067574

Browse files
chore: add file sync daemon tests (#129)
These are just regression tests for the core file sync daemon functionality. Also has sync sessions ignore VCS directories by default, as per the file sync RFC.
1 parent de604d7 commit 8067574

File tree

11 files changed

+274
-65
lines changed

11 files changed

+274
-65
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+5-1
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

+1-1
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

-4
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

+4-3
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: remoteHostname))
106+
)
106107
)
107108
} catch {
108109
createError = error

Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ struct FilePickerTests {
103103
try disclosureGroup.expand()
104104

105105
// Disclosure group should expand out to 3 more directories
106-
try #expect(await eventually { @MainActor in
107-
return try view.findAll(ViewType.DisclosureGroup.self).count == 6
106+
#expect(await eventually { @MainActor in
107+
return view.findAll(ViewType.DisclosureGroup.self).count == 6
108108
})
109109
}
110110
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
// Before each test
20+
init() throws {
21+
tempDir = FileManager.default.makeTempDir()!
22+
#if arch(arm64)
23+
let binaryName = "mutagen-darwin-arm64"
24+
#elseif arch(x86_64)
25+
let binaryName = "mutagen-darwin-amd64"
26+
#endif
27+
mutagenBinary = Bundle.main.url(forResource: binaryName, withExtension: nil)!
28+
mutagenDataDirectory = tempDir.appending(path: "mutagen")
29+
mutagenAlphaDirectory = tempDir.appending(path: "alpha")
30+
try FileManager.default.createDirectory(at: mutagenAlphaDirectory, withIntermediateDirectories: true)
31+
mutagenBetaDirectory = tempDir.appending(path: "beta")
32+
try FileManager.default.createDirectory(at: mutagenBetaDirectory, withIntermediateDirectories: true)
33+
}
34+
35+
// After each test
36+
deinit {
37+
try? FileManager.default.removeItem(at: tempDir)
38+
}
39+
40+
private func statesEqual(_ first: DaemonState, _ second: DaemonState) -> Bool {
41+
switch (first, second) {
42+
case (.stopped, .stopped):
43+
true
44+
case (.running, .running):
45+
true
46+
case (.unavailable, .unavailable):
47+
true
48+
default:
49+
false
50+
}
51+
}
52+
53+
@Test
54+
func fullSync() async throws {
55+
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
56+
#expect(statesEqual(daemon.state, .stopped))
57+
#expect(daemon.sessionState.count == 0)
58+
59+
// The daemon won't start until we create a session
60+
await daemon.tryStart()
61+
#expect(statesEqual(daemon.state, .stopped))
62+
#expect(daemon.sessionState.count == 0)
63+
64+
try await daemon.createSession(
65+
arg: .init(
66+
alpha: .init(
67+
path: mutagenAlphaDirectory.path(),
68+
protocolKind: .local
69+
),
70+
beta: .init(
71+
path: mutagenBetaDirectory.path(),
72+
protocolKind: .local
73+
)
74+
)
75+
)
76+
77+
// Daemon should have started itself
78+
#expect(statesEqual(daemon.state, .running))
79+
#expect(daemon.sessionState.count == 1)
80+
81+
// Write a file to Alpha
82+
let alphaFile = mutagenAlphaDirectory.appendingPathComponent("test.txt")
83+
try "Hello, World!".write(to: alphaFile, atomically: true, encoding: .utf8)
84+
#expect(
85+
await eventually(timeout: .seconds(5), interval: .milliseconds(100)) { @MainActor in
86+
return FileManager.default.fileExists(
87+
atPath: self.mutagenBetaDirectory.appending(path: "test.txt").path()
88+
)
89+
})
90+
91+
try await daemon.deleteSessions(ids: daemon.sessionState.map(\.id))
92+
#expect(daemon.sessionState.count == 0)
93+
// Daemon should have stopped itself once all sessions are deleted
94+
#expect(statesEqual(daemon.state, .stopped))
95+
}
96+
97+
@Test
98+
func autoStopStart() async throws {
99+
let daemon = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
100+
#expect(statesEqual(daemon.state, .stopped))
101+
#expect(daemon.sessionState.count == 0)
102+
103+
try await daemon.createSession(
104+
arg: .init(
105+
alpha: .init(
106+
path: mutagenAlphaDirectory.path(),
107+
protocolKind: .local
108+
),
109+
beta: .init(
110+
path: mutagenBetaDirectory.path(),
111+
protocolKind: .local
112+
)
113+
)
114+
)
115+
116+
try await daemon.createSession(
117+
arg: .init(
118+
alpha: .init(
119+
path: mutagenAlphaDirectory.path(),
120+
protocolKind: .local
121+
),
122+
beta: .init(
123+
path: mutagenBetaDirectory.path(),
124+
protocolKind: .local
125+
)
126+
)
127+
)
128+
129+
#expect(statesEqual(daemon.state, .running))
130+
#expect(daemon.sessionState.count == 2)
131+
132+
try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
133+
#expect(daemon.sessionState.count == 1)
134+
#expect(statesEqual(daemon.state, .running))
135+
136+
try await daemon.deleteSessions(ids: [daemon.sessionState[0].id])
137+
#expect(daemon.sessionState.count == 0)
138+
#expect(statesEqual(daemon.state, .stopped))
139+
}
140+
141+
@Test
142+
func orphaned() async throws {
143+
let daemon1 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
144+
await daemon1.refreshSessions()
145+
try await daemon1.createSession(arg:
146+
.init(
147+
alpha: .init(
148+
path: mutagenAlphaDirectory.path(),
149+
protocolKind: .local
150+
),
151+
beta: .init(
152+
path: mutagenBetaDirectory.path(),
153+
protocolKind: .local
154+
)
155+
)
156+
)
157+
#expect(statesEqual(daemon1.state, .running))
158+
#expect(daemon1.sessionState.count == 1)
159+
160+
let daemon2 = MutagenDaemon(mutagenPath: mutagenBinary, mutagenDataDirectory: mutagenDataDirectory)
161+
await daemon2.tryStart()
162+
#expect(statesEqual(daemon2.state, .running))
163+
164+
// Daemon 2 should have killed daemon 1, causing it to fail
165+
#expect(daemon1.state.isFailed)
166+
}
167+
}

Coder-Desktop/Coder-DesktopTests/Util.swift

+18-10
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

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

68-
var lastError: Error?
69-
7068
while ContinuousClock.now < endTime {
7169
do {
7270
if try await condition() { return true }
73-
lastError = nil
7471
} catch {
75-
lastError = error
7672
try await Task.sleep(for: interval)
7773
}
7874
}
7975

80-
if let lastError {
81-
throw lastError
76+
return try await condition()
77+
}
78+
79+
extension FileManager {
80+
func makeTempDir() -> URL? {
81+
let tempDirectory = FileManager.default.temporaryDirectory
82+
let directoryName = String(Int.random(in: 0 ..< 1_000_000))
83+
let directoryURL = tempDirectory.appendingPathComponent(directoryName)
84+
85+
do {
86+
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
87+
return directoryURL
88+
} catch {
89+
return nil
90+
}
8291
}
83-
return false
8492
}

Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+7-16
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

0 commit comments

Comments
 (0)