Skip to content

Commit 4b72924

Browse files
[PackageToJS] Fix flaky timestampBasedRebuild test by abstracting file system operations
The mtime-based change detection is not very reliable in tests due to the coarse timestamp resolution of some file systems, especially on artificial file operations in tests that happen in quick succession. This change introduces an abstraction over file system operations in MiniMake, allowing tests to use an in-memory file system with precise control over modification times. https://apenwarr.ca/log/20181113
1 parent 627c626 commit 4b72924

File tree

2 files changed

+112
-20
lines changed

2 files changed

+112
-20
lines changed

Plugins/PackageToJS/Sources/MiniMake.swift

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,18 @@ struct MiniMake {
9393
private var tasks: [TaskKey: Task]
9494
/// Whether to explain why tasks are built
9595
private var shouldExplain: Bool
96+
/// File system operations
97+
private var fileSystem: MiniMakeFileSystem
9698
/// Prints progress of the build
9799
private var printProgress: ProgressPrinter.PrintProgress
98100

99101
init(
100102
explain: Bool = false,
103+
fileSystem: MiniMakeFileSystem = DefaultMiniMakeFileSystem(),
101104
printProgress: @escaping ProgressPrinter.PrintProgress
102105
) {
103106
self.tasks = [:]
107+
self.fileSystem = fileSystem
104108
self.shouldExplain = explain
105109
self.printProgress = printProgress
106110
}
@@ -222,7 +226,7 @@ struct MiniMake {
222226
/// Cleans all outputs of all tasks
223227
func cleanEverything(scope: VariableScope) {
224228
for task in self.tasks.values {
225-
try? FileManager.default.removeItem(at: scope.resolve(path: task.output))
229+
try? fileSystem.removeItem(at: scope.resolve(path: task.output))
226230
}
227231
}
228232

@@ -234,26 +238,20 @@ struct MiniMake {
234238
return true
235239
}
236240
let outputURL = scope.resolve(path: task.output)
237-
if !FileManager.default.fileExists(atPath: outputURL.path) {
241+
if !fileSystem.fileExists(at: outputURL) {
238242
explain("Task \(task.output) should be built because it doesn't exist")
239243
return true
240244
}
241-
let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey])
242-
.contentModificationDate
245+
let outputMtime = try? fileSystem.modificationDate(of: outputURL)
243246
return task.inputs.contains { input in
244247
let inputURL = scope.resolve(path: input)
245248
// Ignore directory modification times
246-
var isDirectory: ObjCBool = false
247-
let fileExists = FileManager.default.fileExists(
248-
atPath: inputURL.path,
249-
isDirectory: &isDirectory
250-
)
251-
if fileExists && isDirectory.boolValue {
249+
let (fileExists, isDirectory) = fileSystem.fileExists(at: inputURL)
250+
if fileExists && isDirectory {
252251
return false
253252
}
254253

255-
let inputMtime = try? inputURL.resourceValues(forKeys: [.contentModificationDateKey]
256-
).contentModificationDate
254+
let inputMtime = try? fileSystem.modificationDate(of: inputURL)
257255
let shouldBuild =
258256
outputMtime == nil || inputMtime == nil || outputMtime! < inputMtime!
259257
if shouldBuild {
@@ -337,3 +335,32 @@ struct BuildPath: Encodable, Hashable, CustomStringConvertible {
337335
try container.encode(self.description)
338336
}
339337
}
338+
339+
/// Abstraction over file system operations
340+
protocol MiniMakeFileSystem {
341+
func removeItem(at url: URL) throws
342+
func fileExists(at url: URL) -> Bool
343+
func fileExists(at url: URL) -> (exists: Bool, isDirectory: Bool)
344+
func modificationDate(of url: URL) throws -> Date?
345+
}
346+
347+
/// Default implementation of MiniMakeFileSystem using FileManager
348+
struct DefaultMiniMakeFileSystem: MiniMakeFileSystem {
349+
func removeItem(at url: URL) throws {
350+
try FileManager.default.removeItem(at: url)
351+
}
352+
353+
func fileExists(at url: URL) -> Bool {
354+
FileManager.default.fileExists(atPath: url.path)
355+
}
356+
357+
func fileExists(at url: URL) -> (exists: Bool, isDirectory: Bool) {
358+
var isDirectory: ObjCBool = false
359+
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
360+
return (exists, isDirectory.boolValue)
361+
}
362+
363+
func modificationDate(of url: URL) throws -> Date? {
364+
try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
365+
}
366+
}

Plugins/PackageToJS/Tests/MiniMakeTests.swift

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,67 @@ import Testing
44
@testable import PackageToJS
55

66
@Suite struct MiniMakeTests {
7+
final class InMemoryFileSystem: MiniMakeFileSystem {
8+
struct FileEntry {
9+
var content: Data
10+
var modificationDate: Date
11+
var isDirectory: Bool
12+
}
13+
private var storage: [URL: FileEntry] = [:]
14+
15+
struct MonotonicDateGenerator {
16+
private var currentDate: Date
17+
18+
init(startingFrom date: Date = Date()) {
19+
self.currentDate = date
20+
}
21+
22+
mutating func next() -> Date {
23+
currentDate = currentDate.addingTimeInterval(1)
24+
return currentDate
25+
}
26+
}
27+
var dateGenerator = MonotonicDateGenerator()
28+
29+
// MARK: - MiniMakeFileSystem conformance
30+
31+
func removeItem(at url: URL) throws {
32+
storage.removeValue(forKey: url)
33+
}
34+
35+
func fileExists(at url: URL) -> Bool {
36+
return storage[url] != nil
37+
}
38+
39+
func fileExists(at url: URL) -> (exists: Bool, isDirectory: Bool) {
40+
if let entry = storage[url] {
41+
return (true, entry.isDirectory)
42+
} else {
43+
return (false, false)
44+
}
45+
}
46+
47+
func modificationDate(of url: URL) throws -> Date? {
48+
return storage[url]?.modificationDate
49+
}
50+
51+
func writeFile(at url: URL, content: Data) {
52+
storage[url] = FileEntry(content: content, modificationDate: dateGenerator.next(), isDirectory: false)
53+
}
54+
55+
// MARK: - Helpers for tests
56+
57+
func touch(_ url: URL) {
58+
let date = dateGenerator.next()
59+
if var entry = storage[url] {
60+
entry.modificationDate = date
61+
storage[url] = entry
62+
} else {
63+
storage[url] = FileEntry(content: Data(), modificationDate: date, isDirectory: false)
64+
}
65+
}
66+
}
67+
768
// Test basic task management functionality
869
@Test func basicTaskManagement() throws {
970
try withTemporaryDirectory { tempDir, _ in
@@ -114,7 +175,11 @@ import Testing
114175
// Test that rebuilds are controlled by timestamps
115176
@Test func timestampBasedRebuild() throws {
116177
try withTemporaryDirectory { tempDir, _ in
117-
var make = MiniMake(printProgress: { _, _ in })
178+
let fs = InMemoryFileSystem()
179+
var make = MiniMake(
180+
fileSystem: fs,
181+
printProgress: { _, _ in }
182+
)
118183
let prefix = BuildPath(prefix: "PREFIX")
119184
let scope = MiniMake.VariableScope(variables: [
120185
"PREFIX": tempDir.path
@@ -123,25 +188,25 @@ import Testing
123188
let output = prefix.appending(path: "output.txt")
124189
var buildCount = 0
125190

126-
try "Initial".write(toFile: scope.resolve(path: input).path, atomically: true, encoding: .utf8)
191+
// Create initial input file
192+
fs.touch(scope.resolve(path: input))
127193

128194
let task = make.addTask(inputFiles: [input], output: output) { task, scope in
129195
buildCount += 1
130-
let content = try String(contentsOfFile: scope.resolve(path: task.inputs[0]).path, encoding: .utf8)
131-
try content.write(toFile: scope.resolve(path: task.output).path, atomically: true, encoding: .utf8)
196+
fs.touch(scope.resolve(path: task.output))
132197
}
133198

134199
// First build
135-
try make.build(output: task, scope: scope)
200+
#expect(throws: Never.self) { try make.build(output: task, scope: scope) }
136201
#expect(buildCount == 1, "First build should occur")
137202

138203
// Second build without changes
139-
try make.build(output: task, scope: scope)
204+
#expect(throws: Never.self) { try make.build(output: task, scope: scope) }
140205
#expect(buildCount == 1, "No rebuild should occur if input is not modified")
141206

142207
// Modify input and rebuild
143-
try "Modified".write(toFile: scope.resolve(path: input).path, atomically: true, encoding: .utf8)
144-
try make.build(output: task, scope: scope)
208+
fs.touch(scope.resolve(path: input))
209+
#expect(throws: Never.self) { try make.build(output: task, scope: scope) }
145210
#expect(buildCount == 2, "Should rebuild when input is modified")
146211
}
147212
}

0 commit comments

Comments
 (0)