Skip to content

Commit a31b50d

Browse files
Merge pull request #458 from swiftwasm/yt/fix-race-mtime-in-test
[PackageToJS] Fix flaky `timestampBasedRebuild` test by abstracting file system operations
2 parents 627c626 + 4b72924 commit a31b50d

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)