From 3d2574e8bf220b1c6867339f55242ed02cb477a5 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Sat, 21 Jun 2025 23:47:11 +0530 Subject: [PATCH 1/3] Json Progress File --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 10 +- Sources/Swiftly/Install.swift | 61 +++++--- .../Swiftly/JsonFileProgressReporter.swift | 58 ++++++++ .../SwiftlyCore/FileManager+FilePath.swift | 16 +++ Tests/SwiftlyTests/InstallTests.swift | 34 +++++ .../JsonFileProgressReporterTests.swift | 133 ++++++++++++++++++ 6 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 Sources/Swiftly/JsonFileProgressReporter.swift create mode 100644 Tests/SwiftlyTests/JsonFileProgressReporterTests.swift diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 41d59179..65d975ba 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -23,7 +23,7 @@ swiftly [--version] [--help] Install a new toolchain. ``` -swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--assume-yes] [--verbose] [--version] [--help] +swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--assume-yes] [--verbose] [--version] [--help] ``` **version:** @@ -80,6 +80,14 @@ If the toolchain that is installed has extra post installation steps, they will written to this file as commands that can be run after the installation. +**--progress-file=\:** + +*A file path where progress information will be written in JSONL format* + +Progress information will be appended to this file as JSON objects, one per line. +Each progress entry contains timestamp, progress percentage, and a descriptive message. + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 8481aada..bbf45fa8 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -71,10 +71,20 @@ struct Install: SwiftlyCommand { )) var postInstallFile: FilePath? + @Option( + help: ArgumentHelp( + "A file path where progress information will be written in JSONL format", + discussion: """ + Progress information will be appended to this file as JSON objects, one per line. + Each progress entry contains timestamp, progress percentage, and a descriptive message. + """ + )) + var progressFile: FilePath? + @OptionGroup var root: GlobalOptions private enum CodingKeys: String, CodingKey { - case version, use, verify, postInstallFile, root + case version, use, verify, postInstallFile, root, progressFile } mutating func run() async throws { @@ -93,7 +103,9 @@ struct Install: SwiftlyCommand { try await validateLinked(ctx) var config = try await Config.load(ctx) - let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config) + let toolchainVersion = try await Self.determineToolchainVersion( + ctx, version: self.version, config: &config + ) let (postInstallScript, pathChanged) = try await Self.execute( ctx, @@ -102,7 +114,8 @@ struct Install: SwiftlyCommand { useInstalledToolchain: self.use, verifySignature: self.verify, verbose: self.root.verbose, - assumeYes: self.root.assumeYes + assumeYes: self.root.assumeYes, + progressFile: self.progressFile ) let shell = @@ -192,8 +205,9 @@ struct Install: SwiftlyCommand { await ctx.message("Setting up toolchain proxies...") } - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( - overwrite) + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents) + .union( + overwrite) for p in proxiesToCreate { let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p @@ -248,7 +262,8 @@ struct Install: SwiftlyCommand { useInstalledToolchain: Bool, verifySignature: Bool, verbose: Bool, - assumeYes: Bool + assumeYes: Bool, + progressFile: FilePath? = nil ) async throws -> (postInstall: String?, pathChanged: Bool) { guard !config.installedToolchains.contains(version) else { await ctx.message("\(version) is already installed.") @@ -258,10 +273,11 @@ struct Install: SwiftlyCommand { // Ensure the system is set up correctly before downloading it. Problems that prevent installation // will throw, while problems that prevent use of the toolchain will be written out as a post install // script for the user to run afterwards. - let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall( - ctx, platformName: config.platform.name, version: version, - requireSignatureValidation: verifySignature - ) + let postInstallScript = try await Swiftly.currentPlatform + .verifySystemPrerequisitesForInstall( + ctx, platformName: config.platform.name, version: version, + requireSignatureValidation: verifySignature + ) await ctx.message("Installing \(version)") @@ -296,10 +312,13 @@ struct Install: SwiftlyCommand { } } - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading \(version)" - ) + let animation: ProgressAnimationProtocol = + progressFile != nil + ? JsonFileProgressReporter(filePath: progressFile!) + : PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading \(version)" + ) var lastUpdate = Date() @@ -315,7 +334,9 @@ struct Install: SwiftlyCommand { reportProgress: { progress in let now = Date() - guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes + guard + lastUpdate.distance(to: now) > 0.25 + || progress.receivedBytes == progress.totalBytes else { return } @@ -334,7 +355,8 @@ struct Install: SwiftlyCommand { } ) } catch let notFound as DownloadNotFoundError { - throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") + throw SwiftlyError( + message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { animation.complete(success: false) throw error @@ -401,7 +423,9 @@ struct Install: SwiftlyCommand { } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. - public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector) + public static func resolve( + _ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector + ) async throws -> ToolchainVersion { switch selector { @@ -426,7 +450,8 @@ struct Install: SwiftlyCommand { } if let patch { - return .stable(ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) + return .stable( + ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) } await ctx.message("Fetching the latest stable Swift \(major).\(minor) release...") diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift new file mode 100644 index 00000000..dd06fad8 --- /dev/null +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftlyCore +import SystemPackage +import TSCUtility + +enum ProgressInfo: Codable { + case step(timestamp: Date, percent: Int, text: String) + case complete(success: Bool) +} + +struct JsonFileProgressReporter: ProgressAnimationProtocol { + let filePath: FilePath + private let encoder: JSONEncoder + + init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { + self.filePath = filePath + self.encoder = encoder + } + + private func writeProgress(_ progress: ProgressInfo) { + let jsonData = try? self.encoder.encode(progress) + guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) + else { + print("Failed to encode progress entry to JSON") + return + } + + let jsonLine = jsonString + "\n" + + do { + try jsonLine.append(to: self.filePath) + } catch { + print("Failed to write progress entry to \(self.filePath): \(error)") + } + } + + func update(step: Int, total: Int, text: String) { + assert(step <= total) + self.writeProgress( + ProgressInfo.step( + timestamp: Date(), + percent: Int(Double(step) / Double(total) * 100), + text: text + )) + } + + func complete(success: Bool) { + self.writeProgress(ProgressInfo.complete(success: success)) + } + + func clear() { + do { + try FileManager.default.removeItem(atPath: self.filePath.string) + } catch { + print("Failed to clear progress file at \(self.filePath): \(error)") + } + } +} diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index 5d1453b4..af0db286 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,6 +190,22 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } + public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { + if !FileManager.default.fileExists(atPath: path.string) { + try self.write(to: path, atomically: true, encoding: enc) + return + } + + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) + defer { fileHandle.closeFile() } + fileHandle.seekToEndOfFile() + if let data = self.data(using: enc) { + fileHandle.write(data) + } else { + throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") + } + } + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 3bd0c622..3954702e 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct InstallTests { @@ -262,4 +263,37 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: ToolchainVersion.newStable.name, args: ["--use"]) try await SwiftlyTests.validateInUse(expected: .newStable) } + + /// Verify that progress information is written to the progress file when specified. + @Test(.testHomeMockedToolchain()) func installProgressFile() async throws { + let progressFile = fs.mktemp(ext: ".json") + + try await SwiftlyTests.runCommand(Install.self, [ + "install", "5.7.0", + "--post-install-file=\(fs.mktemp())", + "--progress-file=\(progressFile.string)", + ]) + + #expect(try await fs.exists(atPath: progressFile)) + + let progressContent = try String(contentsOfFile: progressFile.string) + let lines = progressContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + + #expect(!lines.isEmpty, "Progress file should contain progress entries") + + // Verify that at least one progress entry exists + let hasProgressEntry = lines.contains { line in + line.contains("\"step\"") && line.contains("\"percent\"") && line.contains("\"timestamp\"") + } + #expect(hasProgressEntry, "Progress file should contain step progress entries") + + // Verify that a completion entry exists + let hasCompletionEntry = lines.contains { line in + line.contains("\"complete\"") && line.contains("\"success\"") + } + #expect(hasCompletionEntry, "Progress file should contain completion entry") + + // Clean up + try FileManager.default.removeItem(atPath: progressFile.string) + } } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift new file mode 100644 index 00000000..3e6d3f7e --- /dev/null +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -0,0 +1,133 @@ +import Foundation +@testable import Swiftly +@testable import SwiftlyCore +import SystemPackage +import Testing + +@Suite("JsonFileProgressReporter Tests") +struct JsonFileProgressReporterTests { + @Test("Test update method writes progress to file") + func testUpdateWritesProgressToFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 10, text: "Processing item 1") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("Processing item 1")) + #expect(fileContent.contains("\"percent\":10")) + #expect(fileContent.contains("\"step\"")) + #expect(fileContent.contains("\"timestamp\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test complete method writes completion status") + func testCompleteWritesCompletionStatus() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.complete(success: true) + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"success\":true")) + #expect(fileContent.contains("\"complete\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test complete method writes failure status") + func testCompleteWritesFailureStatus() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.complete(success: false) + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"success\":false")) + #expect(fileContent.contains("\"complete\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test percentage calculation") + func testPercentageCalculation() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 25, total: 100, text: "Quarter way") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":25")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test clear method removes file") + func testClearRemovesFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 2, text: "Test") + + #expect(FileManager.default.fileExists(atPath: tempFile.string)) + + reporter.clear() + + #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + } + + @Test("Test multiple progress updates create multiple lines") + func testMultipleUpdatesCreateMultipleLines() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 3, text: "Step 1") + reporter.update(step: 2, total: 3, text: "Step 2") + reporter.complete(success: true) + + let fileContent = try String(contentsOfFile: tempFile.string) + let lines = fileContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + + #expect(lines.count == 3) + #expect(lines[0].contains("Step 1")) + #expect(lines[1].contains("Step 2")) + #expect(lines[2].contains("\"success\":true")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test zero step edge case") + func testZeroStepEdgeCase() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 0, total: 10, text: "Starting") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":0")) + #expect(fileContent.contains("Starting")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test full completion edge case") + func testFullCompletionEdgeCase() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 100, total: 100, text: "Done") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":100")) + #expect(fileContent.contains("Done")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } +} From 6a07745565087c6093b451711fe59fe43683dcb6 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 00:02:03 +0530 Subject: [PATCH 2/3] Improve JSON progress file handling and error reporting --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 1 + Sources/Swiftly/Install.swift | 17 +- .../Swiftly/JsonFileProgressReporter.swift | 36 +-- .../SwiftlyCore/FileManager+FilePath.swift | 11 +- .../JsonFileProgressReporterTests.swift | 210 ++++++++++-------- 5 files changed, 153 insertions(+), 122 deletions(-) diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 65d975ba..8896f5d1 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -86,6 +86,7 @@ written to this file as commands that can be run after the installation. Progress information will be appended to this file as JSON objects, one per line. Each progress entry contains timestamp, progress percentage, and a descriptive message. +The file must be writable, else an error will be thrown. **--assume-yes:** diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index bbf45fa8..d8078b61 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -77,6 +77,7 @@ struct Install: SwiftlyCommand { discussion: """ Progress information will be appended to this file as JSON objects, one per line. Each progress entry contains timestamp, progress percentage, and a descriptive message. + The file must be writable, else an error will be thrown. """ )) var progressFile: FilePath? @@ -313,12 +314,16 @@ struct Install: SwiftlyCommand { } let animation: ProgressAnimationProtocol = - progressFile != nil - ? JsonFileProgressReporter(filePath: progressFile!) - : PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading \(version)" - ) + if let progressFile + { + try JsonFileProgressReporter(ctx, filePath: progressFile) + } else { + PercentProgressAnimation(stream: stdoutStream, header: "Downloading \(version)") + } + + defer { + try? (animation as? JsonFileProgressReporter)?.close() + } var lastUpdate = Date() diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift index dd06fad8..3273a523 100644 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -11,27 +11,30 @@ enum ProgressInfo: Codable { struct JsonFileProgressReporter: ProgressAnimationProtocol { let filePath: FilePath private let encoder: JSONEncoder + private let ctx: SwiftlyCoreContext + private let fileHandle: FileHandle - init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { + init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws + { + self.ctx = ctx self.filePath = filePath self.encoder = encoder + self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) } private func writeProgress(_ progress: ProgressInfo) { let jsonData = try? self.encoder.encode(progress) - guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) - else { - print("Failed to encode progress entry to JSON") + guard let jsonData = jsonData else { + Task { [ctx = self.ctx] in + await ctx.message("Failed to encode progress entry to JSON") + } return } - let jsonLine = jsonString + "\n" - - do { - try jsonLine.append(to: self.filePath) - } catch { - print("Failed to write progress entry to \(self.filePath): \(error)") - } + self.fileHandle.seekToEndOfFile() + self.fileHandle.write(jsonData) + self.fileHandle.write("\n".data(using: .utf8) ?? Data()) + self.fileHandle.synchronizeFile() } func update(step: Int, total: Int, text: String) { @@ -49,10 +52,11 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { } func clear() { - do { - try FileManager.default.removeItem(atPath: self.filePath.string) - } catch { - print("Failed to clear progress file at \(self.filePath): \(error)") - } + self.fileHandle.truncateFile(atOffset: 0) + self.fileHandle.synchronizeFile() + } + + func close() throws { + try self.fileHandle.close() } } diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index af0db286..ac40e297 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -60,7 +60,7 @@ public enum FileSystem { case mode(Int) } - public static func create(_ options: CreateOptions..., file: FilePath, contents: Data?) async throws { + public static func create(_ options: CreateOptions..., file: FilePath, contents: Data? = nil) async throws { try await Self.create(options, file: file, contents: contents) } @@ -195,15 +195,6 @@ extension String { try self.write(to: path, atomically: true, encoding: enc) return } - - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) - defer { fileHandle.closeFile() } - fileHandle.seekToEndOfFile() - if let data = self.data(using: enc) { - fileHandle.write(data) - } else { - throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") - } } public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 3e6d3f7e..e62c0f22 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -1,133 +1,163 @@ import Foundation -@testable import Swiftly -@testable import SwiftlyCore import SystemPackage import Testing -@Suite("JsonFileProgressReporter Tests") -struct JsonFileProgressReporterTests { - @Test("Test update method writes progress to file") - func testUpdateWritesProgressToFile() throws { +@testable import Swiftly +@testable import SwiftlyCore + +@Suite struct JsonFileProgressReporterTests { + @Test("Test update method writes progress to file as valid JSONNL") + func testUpdateWritesProgressToFile() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) reporter.update(step: 1, total: 10, text: "Processing item 1") - - let fileContent = try String(contentsOfFile: tempFile.string) - - #expect(fileContent.contains("Processing item 1")) - #expect(fileContent.contains("\"percent\":10")) - #expect(fileContent.contains("\"step\"")) - #expect(fileContent.contains("\"timestamp\"")) - - try FileManager.default.removeItem(atPath: tempFile.string) + try reporter.close() + + let decoder = JSONDecoder() + + let info = try String(contentsOfFile: tempFile.string).split(separator: "\n") + .filter { + !$0.isEmpty + }.map { + try decoder.decode( + ProgressInfo.self, + from: Data($0.trimmingCharacters(in: .whitespacesAndNewlines).utf8) + ) + } + + #expect(info.count == 1) + + if case let .step(timestamp, percent, text) = info.first { + #expect(text == "Processing item 1") + #expect(percent == 10) + #expect(timestamp.timeIntervalSince1970 > 0) + } else { + Issue.record("Expected step info but got \(info[0])") + return + } } @Test("Test complete method writes completion status") - func testCompleteWritesCompletionStatus() throws { + func testCompleteWritesCompletionStatus() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) - - reporter.complete(success: true) - - let fileContent = try String(contentsOfFile: tempFile.string) - - #expect(fileContent.contains("\"success\":true")) - #expect(fileContent.contains("\"complete\"")) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } - try FileManager.default.removeItem(atPath: tempFile.string) - } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - @Test("Test complete method writes failure status") - func testCompleteWritesFailureStatus() throws { - let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + let status = Bool.random() + reporter.complete(success: status) + try reporter.close() - reporter.complete(success: false) + let decoder = JSONDecoder() - let fileContent = try String(contentsOfFile: tempFile.string) + let info = try String(contentsOfFile: tempFile.string).split(separator: "\n") + .filter { + !$0.isEmpty + }.map { + try decoder.decode(ProgressInfo.self, from: Data($0.utf8)) + } - #expect(fileContent.contains("\"success\":false")) - #expect(fileContent.contains("\"complete\"")) + #expect(info.count == 1) - try FileManager.default.removeItem(atPath: tempFile.string) + if case let .complete(success) = info.first { + #expect(success == status) + } else { + Issue.record("Expected completion info but got \(info)") + return + } } @Test("Test percentage calculation") - func testPercentageCalculation() throws { + func testPercentageCalculation() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) reporter.update(step: 25, total: 100, text: "Quarter way") - - let fileContent = try String(contentsOfFile: tempFile.string) - - #expect(fileContent.contains("\"percent\":25")) + try reporter.close() + + let decoder = JSONDecoder() + let info = try String(contentsOfFile: tempFile.string).split(separator: "\n") + .filter { + !$0.isEmpty + }.map { + try decoder.decode(ProgressInfo.self, from: Data($0.utf8)) + } + #expect(info.count == 1) + if case let .step(_, percent, text) = info.first { + #expect(percent == 25) + #expect(text == "Quarter way") + } else { + Issue.record("Expected step info but got \(info)") + return + } try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method removes file") - func testClearRemovesFile() throws { + @Test("Test clear method truncates the file") + func testClearTruncatesFile() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) + defer { try? reporter.close() } reporter.update(step: 1, total: 2, text: "Test") - #expect(FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) reporter.clear() - #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) } @Test("Test multiple progress updates create multiple lines") - func testMultipleUpdatesCreateMultipleLines() throws { + func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } - reporter.update(step: 1, total: 3, text: "Step 1") - reporter.update(step: 2, total: 3, text: "Step 2") - reporter.complete(success: true) + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - let fileContent = try String(contentsOfFile: tempFile.string) - let lines = fileContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + reporter.update(step: 5, total: 100, text: "Processing item 5") + reporter.update(step: 10, total: 100, text: "Processing item 10") + reporter.update(step: 50, total: 100, text: "Processing item 50") + reporter.update(step: 100, total: 100, text: "Processing item 100") - #expect(lines.count == 3) - #expect(lines[0].contains("Step 1")) - #expect(lines[1].contains("Step 2")) - #expect(lines[2].contains("\"success\":true")) - - try FileManager.default.removeItem(atPath: tempFile.string) - } - - @Test("Test zero step edge case") - func testZeroStepEdgeCase() throws { - let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) - - reporter.update(step: 0, total: 10, text: "Starting") - - let fileContent = try String(contentsOfFile: tempFile.string) - - #expect(fileContent.contains("\"percent\":0")) - #expect(fileContent.contains("Starting")) - - try FileManager.default.removeItem(atPath: tempFile.string) - } - - @Test("Test full completion edge case") - func testFullCompletionEdgeCase() throws { - let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) - - reporter.update(step: 100, total: 100, text: "Done") - - let fileContent = try String(contentsOfFile: tempFile.string) - - #expect(fileContent.contains("\"percent\":100")) - #expect(fileContent.contains("Done")) - - try FileManager.default.removeItem(atPath: tempFile.string) + reporter.complete(success: true) + try? reporter.close() + + let decoder = JSONDecoder() + let info = try String(contentsOfFile: tempFile.string).split(separator: "\n") + .filter { + !$0.isEmpty + }.map { + try decoder.decode(ProgressInfo.self, from: Data($0.utf8)) + } + + #expect(info.count == 5) + + for (idx, pct) in [5, 10, 50, 100].enumerated() { + if case let .step(_, percent, text) = info[idx] { + #expect(text == "Processing item \(pct)") + #expect(percent == pct) + } else { + Issue.record("Expected step info but got \(info[idx])") + return + } + } + + if case let .complete(success) = info[4] { + #expect(success == true) + } else { + Issue.record("Expected completion info but got \(info[4])") + return + } } } From 12a74b5b4b29550e9b607220819b22849f96e823 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 21:35:11 +0530 Subject: [PATCH 3/3] Refactor progress file parsing and improve test validation --- .../Swiftly/JsonFileProgressReporter.swift | 10 +- .../SwiftlyCore/FileManager+FilePath.swift | 7 -- Tests/SwiftlyTests/InstallTests.swift | 109 ++++++++++++++++-- .../JsonFileProgressReporterTests.swift | 17 --- 4 files changed, 106 insertions(+), 37 deletions(-) diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift index 3273a523..76fafc8b 100644 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -31,14 +31,15 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { return } - self.fileHandle.seekToEndOfFile() self.fileHandle.write(jsonData) self.fileHandle.write("\n".data(using: .utf8) ?? Data()) - self.fileHandle.synchronizeFile() + try? self.fileHandle.synchronize() } func update(step: Int, total: Int, text: String) { - assert(step <= total) + guard total > 0 && step <= total else { + return + } self.writeProgress( ProgressInfo.step( timestamp: Date(), @@ -52,8 +53,7 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { } func clear() { - self.fileHandle.truncateFile(atOffset: 0) - self.fileHandle.synchronizeFile() + // not implemented for JSON file reporter } func close() throws { diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index ac40e297..abac3487 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,13 +190,6 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } - public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { - if !FileManager.default.fileExists(atPath: path.string) { - try self.write(to: path, atomically: true, encoding: enc) - return - } - } - public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 3954702e..590d9b0a 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -267,6 +267,7 @@ import Testing /// Verify that progress information is written to the progress file when specified. @Test(.testHomeMockedToolchain()) func installProgressFile() async throws { let progressFile = fs.mktemp(ext: ".json") + try await fs.create(.mode(0o644), file: progressFile) try await SwiftlyTests.runCommand(Install.self, [ "install", "5.7.0", @@ -276,24 +277,116 @@ import Testing #expect(try await fs.exists(atPath: progressFile)) + let decoder = JSONDecoder() let progressContent = try String(contentsOfFile: progressFile.string) - let lines = progressContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + let progressInfo = try progressContent.split(separator: "\n") + .filter { !$0.isEmpty } + .map { line in + try decoder.decode(ProgressInfo.self, from: Data(line.utf8)) + } - #expect(!lines.isEmpty, "Progress file should contain progress entries") + #expect(!progressInfo.isEmpty, "Progress file should contain progress entries") - // Verify that at least one progress entry exists - let hasProgressEntry = lines.contains { line in - line.contains("\"step\"") && line.contains("\"percent\"") && line.contains("\"timestamp\"") + // Verify that at least one step progress entry exists + let hasStepEntry = progressInfo.contains { info in + if case .step = info { return true } + return false } - #expect(hasProgressEntry, "Progress file should contain step progress entries") + #expect(hasStepEntry, "Progress file should contain step progress entries") // Verify that a completion entry exists - let hasCompletionEntry = lines.contains { line in - line.contains("\"complete\"") && line.contains("\"success\"") + let hasCompletionEntry = progressInfo.contains { info in + if case .complete = info { return true } + return false } #expect(hasCompletionEntry, "Progress file should contain completion entry") // Clean up try FileManager.default.removeItem(atPath: progressFile.string) } + +#if os(Linux) || os(macOS) + @Test(.testHomeMockedToolchain()) + func installProgressFileNamedPipe() async throws { + let tempDir = NSTemporaryDirectory() + let pipePath = tempDir + "swiftly_install_progress_pipe" + + let result = mkfifo(pipePath, 0o644) + guard result == 0 else { + return // Skip test if mkfifo syscall failed + } + + defer { + try? FileManager.default.removeItem(atPath: pipePath) + } + + var receivedMessages: [ProgressInfo] = [] + let decoder = JSONDecoder() + var installCompleted = false + + let readerTask = Task { + guard let fileHandle = FileHandle(forReadingAtPath: pipePath) else { return } + defer { fileHandle.closeFile() } + + var buffer = Data() + + while !installCompleted { + let data = fileHandle.availableData + if data.isEmpty { + try await Task.sleep(nanoseconds: 100_000_000) + continue + } + + buffer.append(data) + + while let newlineRange = buffer.range(of: "\n".data(using: .utf8)!) { + let lineData = buffer.subdata(in: 0.. 0) + #expect(percent >= 0 && percent <= 100) + #expect(!text.isEmpty) + case let .complete(success): + #expect(success == true) + } + } + } +#endif } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index e62c0f22..70f0fad0 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -100,23 +100,6 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method truncates the file") - func testClearTruncatesFile() async throws { - let tempFile = fs.mktemp(ext: ".json") - try await fs.create(.mode(Int(0o644)), file: tempFile) - defer { try? FileManager.default.removeItem(atPath: tempFile.string) } - let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - defer { try? reporter.close() } - - reporter.update(step: 1, total: 2, text: "Test") - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) - - reporter.clear() - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) - } - @Test("Test multiple progress updates create multiple lines") func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json")