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

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

Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift

Lines changed: 2 additions & 2 deletions
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
}
Lines changed: 167 additions & 0 deletions
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

Lines changed: 18 additions & 10 deletions
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

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

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy