From 56adc6ab3013b7b5522909824dc91abd7e8e2c8e Mon Sep 17 00:00:00 2001 From: Pedro Date: Wed, 11 Dec 2024 13:36:45 +0100 Subject: [PATCH 01/12] Add progress step --- Package.swift | 6 +- Sources/Noora/Components/ProgressStep.swift | 76 +++++++++++++++++++ Sources/Noora/Utilities/Spinner.swift | 48 ++++++------ .../Commands/ProgressStepCommand.swift | 22 ++++++ Sources/examples-cli/ExamplesCLI.swift | 2 +- 5 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 Sources/Noora/Components/ProgressStep.swift create mode 100644 Sources/examples-cli/Commands/ProgressStepCommand.swift diff --git a/Package.swift b/Package.swift index bde38a9..f4b0aba 100644 --- a/Package.swift +++ b/Package.swift @@ -33,16 +33,16 @@ let package = Package( .target( name: "Noora", dependencies: [ - "Rainbow", + "Rainbow" ], swiftSettings: [ - .define("MOCKING", .when(configuration: .debug)), + .define("MOCKING", .when(configuration: .debug)) ] ), .testTarget( name: "NooraTests", dependencies: [ - "Noora", + "Noora" ] ), ] diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift new file mode 100644 index 0000000..f4018b9 --- /dev/null +++ b/Sources/Noora/Components/ProgressStep.swift @@ -0,0 +1,76 @@ +import Foundation +import Rainbow + +class ProgressStep { + // MARK: - Attributes + + let message: String + let successMessage: String? + let errorMessage: String? + let showSpinner: Bool + let action: (@escaping (String) -> Void) async throws -> Void + let theme: Theme + let terminal: Terminaling + let renderer: Rendering + let standardPipelines: StandardPipelines + var spinner = Spinner() + + init(message: String, successMessage: String?, errorMessage: String?, showSpinner: Bool, action: @escaping (@escaping (String) -> Void) async throws -> Void, theme: Theme, terminal: Terminaling, renderer: Rendering, standardPipelines: StandardPipelines, spinner: Spinner = Spinner()) { + self.message = message + self.successMessage = successMessage + self.errorMessage = errorMessage + self.showSpinner = showSpinner + self.action = action + self.theme = theme + self.terminal = terminal + self.renderer = renderer + self.standardPipelines = standardPipelines + self.spinner = spinner + } + + func run() async throws { + let start = DispatchTime.now() + + defer { spinner.stop() } + + var spinnerIcon: String? + var lastMessage = message + + if showSpinner { + spinner.spin { icon in + spinnerIcon = icon + self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + } + } + + var _error: Error? = nil + do { + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + try await action({ progressMessage in + lastMessage = progressMessage + self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + }) + } catch { + _error = error + } + + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) + + if _error != nil { + renderer.render("\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + } else { + renderer.render("\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(successMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + } + + if let _error = _error { + throw _error + } + } + + // MARK: - Private + + private func render(message: String, icon: String) { + renderer.render("\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message)", standardPipeline: standardPipelines.output) + } +} diff --git a/Sources/Noora/Utilities/Spinner.swift b/Sources/Noora/Utilities/Spinner.swift index 6f6c761..0bee37e 100644 --- a/Sources/Noora/Utilities/Spinner.swift +++ b/Sources/Noora/Utilities/Spinner.swift @@ -1,15 +1,6 @@ import Foundation -enum Spinner { - typealias Cancellable = () -> Void - - actor Counter { - var count: Int = 0 - - func increase() { - count += 1 - } - } +class Spinner { private static let frames = [ "⠋", @@ -23,23 +14,34 @@ enum Spinner { "⠇", "⠏", ] + private var isSpinning = true + private var timer: Timer? - static func spin(_ block: @escaping (String) async -> Void) async -> Cancellable { - let counter = Counter() - await block(Spinner.frames[0]) + func spin(_ block: @escaping (String) -> Void) { + isSpinning = true - let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) - timer.schedule(deadline: .now(), repeating: 0.1) - timer.setEventHandler { - Task { - await block(Spinner.frames[await counter.count % Spinner.frames.count]) - await counter.increase() + DispatchQueue.global(qos: .userInitiated).async { + let runLoop = RunLoop.current + var index = 0 + + // Schedule the timer in the current run loop + self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if self.isSpinning { + block(Spinner.frames[index]) + index = (index + 1) % Spinner.frames.count + } else { + self.timer?.invalidate() + } } - } - timer.resume() - return { - timer.cancel() + // Start the run loop to allow the timer to fire + while self.isSpinning && runLoop.run(mode: .default, before: .distantFuture) {} } } + + func stop() { + isSpinning = false + timer?.invalidate() + timer = nil + } } diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift new file mode 100644 index 0000000..43e6816 --- /dev/null +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import Foundation +import Noora + +struct ProgressStepCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "progress-step", + abstract: "A component to shows a progress step" + ) + + func run() async throws { + try await Noora().progressStep(message: "Loading manifests", successMessage: "Manifests loaded", errorMessage: "Failed to load manifests") { progress in + sleep(2) + } + try await Noora().progressStep(message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph") { progress in + sleep(2) + } + try await Noora().progressStep(message: "Generating Xcode projects and workspsace", successMessage: "Xcode projects and workspace generated", errorMessage: "Failed to generate Xcode workspace and projects") { progress in + sleep(2) + } + } +} diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index 1227e64..62b76e2 100644 --- a/Sources/examples-cli/ExamplesCLI.swift +++ b/Sources/examples-cli/ExamplesCLI.swift @@ -6,6 +6,6 @@ import Rainbow struct ExamplesCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "A command line tool to test the different components available in Noora.", - subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self] + subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self, ProgressStepCommand.self] ) } From c1f9eac8a8e3c9fc67c40c640dd59f635c522fe3 Mon Sep 17 00:00:00 2001 From: Pedro Date: Wed, 11 Dec 2024 17:15:23 +0100 Subject: [PATCH 02/12] Align completion messages --- Package.swift | 6 +- Sources/Noora/Components/ProgressStep.swift | 59 +++++++++++++------ .../Noora/Components/SingleChoicePrompt.swift | 5 +- .../Components/YesOrNoChoicePrompt.swift | 6 +- Sources/Noora/Noora.swift | 21 +++++++ Sources/Noora/Utilities/Spinner.swift | 3 +- .../Commands/ProgressStepCommand.swift | 18 +++++- Sources/examples-cli/ExamplesCLI.swift | 7 ++- .../Components/SingleChoicePromptTests.swift | 2 +- .../Components/YesOrNoChoicePromptTests.swift | 2 +- 10 files changed, 99 insertions(+), 30 deletions(-) diff --git a/Package.swift b/Package.swift index f4b0aba..bde38a9 100644 --- a/Package.swift +++ b/Package.swift @@ -33,16 +33,16 @@ let package = Package( .target( name: "Noora", dependencies: [ - "Rainbow" + "Rainbow", ], swiftSettings: [ - .define("MOCKING", .when(configuration: .debug)) + .define("MOCKING", .when(configuration: .debug)), ] ), .testTarget( name: "NooraTests", dependencies: [ - "Noora" + "Noora", ] ), ] diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift index f4018b9..3d1d258 100644 --- a/Sources/Noora/Components/ProgressStep.swift +++ b/Sources/Noora/Components/ProgressStep.swift @@ -14,8 +14,19 @@ class ProgressStep { let renderer: Rendering let standardPipelines: StandardPipelines var spinner = Spinner() - - init(message: String, successMessage: String?, errorMessage: String?, showSpinner: Bool, action: @escaping (@escaping (String) -> Void) async throws -> Void, theme: Theme, terminal: Terminaling, renderer: Rendering, standardPipelines: StandardPipelines, spinner: Spinner = Spinner()) { + + init( + message: String, + successMessage: String?, + errorMessage: String?, + showSpinner: Bool, + action: @escaping (@escaping (String) -> Void) async throws -> Void, + theme: Theme, + terminal: Terminaling, + renderer: Rendering, + standardPipelines: StandardPipelines, + spinner: Spinner = Spinner() + ) { self.message = message self.successMessage = successMessage self.errorMessage = errorMessage @@ -30,47 +41,61 @@ class ProgressStep { func run() async throws { let start = DispatchTime.now() - + defer { spinner.stop() } - + var spinnerIcon: String? var lastMessage = message - + if showSpinner { spinner.spin { icon in spinnerIcon = icon self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") } } - - var _error: Error? = nil + + var _error: Error? do { render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") - try await action({ progressMessage in + try await action { progressMessage in lastMessage = progressMessage self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") - }) + } } catch { _error = error } - + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) - + if _error != nil { - renderer.render("\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + renderer.render( + "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", + standardPipeline: standardPipelines.output + ) } else { - renderer.render("\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(successMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + renderer.render( + ProgressStep + .completionMessage(successMessage ?? message, timeString: timeString, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) } - - if let _error = _error { + + if let _error { throw _error } } // MARK: - Private - + + static func completionMessage(_ message: String, timeString: String? = nil, theme: Theme, terminal: Terminaling) -> String { + "\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(message)\(timeString != nil ? " \(timeString!)" : "")" + } + private func render(message: String, icon: String) { - renderer.render("\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message)", standardPipeline: standardPipelines.output) + renderer.render( + "\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message)", + standardPipeline: standardPipelines.output + ) } } diff --git a/Sources/Noora/Components/SingleChoicePrompt.swift b/Sources/Noora/Components/SingleChoicePrompt.swift index 790f83f..c530f23 100644 --- a/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/Sources/Noora/Components/SingleChoicePrompt.swift @@ -63,7 +63,10 @@ struct SingleChoicePrompt .boldIfColoredTerminal(terminal) } content += " \(selectedOption.description)" - renderer.render(content, standardPipeline: standardPipelines.output) + renderer.render( + ProgressStep.completionMessage(content, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) } private func renderOptions(selectedOption: T) { diff --git a/Sources/Noora/Components/YesOrNoChoicePrompt.swift b/Sources/Noora/Components/YesOrNoChoicePrompt.swift index 3d7186b..b432d05 100644 --- a/Sources/Noora/Components/YesOrNoChoicePrompt.swift +++ b/Sources/Noora/Components/YesOrNoChoicePrompt.swift @@ -62,7 +62,11 @@ struct YesOrNoChoicePrompt { .boldIfColoredTerminal(terminal) } content += " \(answer ? "Yes" : "No")" - renderer.render(content, standardPipeline: standardPipelines.output) + + renderer.render( + ProgressStep.completionMessage(content, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) } private func renderOptions(answer: Bool) { diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 6e2c6e8..ff66153 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -200,4 +200,25 @@ public struct Noora: Noorable { theme: theme ).run() } + + public func progressStep( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + showSpinner: Bool = true, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws { + let progressStep = ProgressStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + showSpinner: showSpinner, + action: action, + theme: theme, + terminal: terminal, + renderer: Renderer(), + standardPipelines: StandardPipelines() + ) + try await progressStep.run() + } } diff --git a/Sources/Noora/Utilities/Spinner.swift b/Sources/Noora/Utilities/Spinner.swift index 0bee37e..8bba11f 100644 --- a/Sources/Noora/Utilities/Spinner.swift +++ b/Sources/Noora/Utilities/Spinner.swift @@ -1,7 +1,6 @@ import Foundation class Spinner { - private static let frames = [ "⠋", "⠙", @@ -35,7 +34,7 @@ class Spinner { } // Start the run loop to allow the timer to fire - while self.isSpinning && runLoop.run(mode: .default, before: .distantFuture) {} + while self.isSpinning, runLoop.run(mode: .default, before: .distantFuture) {} } } diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift index 43e6816..30a37ac 100644 --- a/Sources/examples-cli/Commands/ProgressStepCommand.swift +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -9,13 +9,25 @@ struct ProgressStepCommand: AsyncParsableCommand { ) func run() async throws { - try await Noora().progressStep(message: "Loading manifests", successMessage: "Manifests loaded", errorMessage: "Failed to load manifests") { progress in + try await Noora().progressStep( + message: "Loading manifests", + successMessage: "Manifests loaded", + errorMessage: "Failed to load manifests" + ) { _ in sleep(2) } - try await Noora().progressStep(message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph") { progress in + try await Noora().progressStep( + message: "Processing the graph", + successMessage: "Project graph processed", + errorMessage: "Failed to process the project graph" + ) { _ in sleep(2) } - try await Noora().progressStep(message: "Generating Xcode projects and workspsace", successMessage: "Xcode projects and workspace generated", errorMessage: "Failed to generate Xcode workspace and projects") { progress in + try await Noora().progressStep( + message: "Generating Xcode projects and workspsace", + successMessage: "Xcode projects and workspace generated", + errorMessage: "Failed to generate Xcode workspace and projects" + ) { _ in sleep(2) } } diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index 62b76e2..d3d28c3 100644 --- a/Sources/examples-cli/ExamplesCLI.swift +++ b/Sources/examples-cli/ExamplesCLI.swift @@ -6,6 +6,11 @@ import Rainbow struct ExamplesCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "A command line tool to test the different components available in Noora.", - subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self, ProgressStepCommand.self] + subcommands: [ + SingleChoicePromptCommand.self, + YesOrNoChoicePromptCommand.self, + AlertCommand.self, + ProgressStepCommand.self, + ] ) } diff --git a/Tests/NooraTests/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index cd67628..ab69c47 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -66,7 +66,7 @@ struct SingleChoicePromptTests { ↑/↓/k/j up/down • enter confirm """) #expect(renders.popLast() == """ - Integration: option1 + ✔︎ Integration: option1 """) } } diff --git a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift index 398a369..2fd9bd4 100644 --- a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift +++ b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift @@ -46,7 +46,7 @@ struct YesOrNoChoicePromptTests { ←/→/h/l left/right • enter confirm """) #expect(renders.popLast() == """ - Authentication: Yes + ✔︎ Authentication: Yes """) } } From 2ef5873ad2278d8f1ca83f3b70e7001179de64ce Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 26 Dec 2024 16:25:02 +0100 Subject: [PATCH 03/12] Add tests --- Sources/Noora/Components/ProgressStep.swift | 10 +- Sources/Noora/Utilities/Spinner.swift | 7 +- .../Components/ProgressStepTests.swift | 132 ++++++++++++++++++ Tests/NooraTests/Mocks/MockSpinner.swift | 12 ++ 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 Tests/NooraTests/Components/ProgressStepTests.swift create mode 100644 Tests/NooraTests/Mocks/MockSpinner.swift diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift index 3d1d258..7a1f8c8 100644 --- a/Sources/Noora/Components/ProgressStep.swift +++ b/Sources/Noora/Components/ProgressStep.swift @@ -13,7 +13,7 @@ class ProgressStep { let terminal: Terminaling let renderer: Rendering let standardPipelines: StandardPipelines - var spinner = Spinner() + var spinner: Spinning init( message: String, @@ -25,7 +25,7 @@ class ProgressStep { terminal: Terminaling, renderer: Rendering, standardPipelines: StandardPipelines, - spinner: Spinner = Spinner() + spinner: Spinning = Spinner() ) { self.message = message self.successMessage = successMessage @@ -42,7 +42,11 @@ class ProgressStep { func run() async throws { let start = DispatchTime.now() - defer { spinner.stop() } + defer { + if showSpinner { + spinner.stop() + } + } var spinnerIcon: String? var lastMessage = message diff --git a/Sources/Noora/Utilities/Spinner.swift b/Sources/Noora/Utilities/Spinner.swift index 8bba11f..fdb05f9 100644 --- a/Sources/Noora/Utilities/Spinner.swift +++ b/Sources/Noora/Utilities/Spinner.swift @@ -1,6 +1,11 @@ import Foundation -class Spinner { +protocol Spinning { + func spin(_ block: @escaping (String) -> Void) + func stop() +} + +class Spinner: Spinning { private static let frames = [ "⠋", "⠙", diff --git a/Tests/NooraTests/Components/ProgressStepTests.swift b/Tests/NooraTests/Components/ProgressStepTests.swift new file mode 100644 index 0000000..0088bcd --- /dev/null +++ b/Tests/NooraTests/Components/ProgressStepTests.swift @@ -0,0 +1,132 @@ +import Testing + +@testable import Noora + +struct ProgressStepTests { + enum TestError: Error, Equatable { + case loadError + } + + let renderer = MockRenderer() + let terminal = MockTerminal() + let spinner = MockSpinner() + + @Test func renders_the_right_output_when_spinner_and_success() async throws { + // Given + let standardPipelines = StandardPipelines() + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: terminal, + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project at path Project/") + #expect(renders.popLast()?.range(of: "✔︎ Project graph loaded \\[.*s\\]", options: .regularExpression) != nil) + #expect(spinner.stoppedCalls == 1) + } + + @Test func renders_the_right_output_when_spinner_and_failure() async throws { + // Given + let standardPipelines = StandardPipelines() + let error = TestError.loadError + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: terminal, + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast()?.range(of: "⨯ Failed to load the project graph \\[.*s\\]", options: .regularExpression) != nil) + #expect(spinner.stoppedCalls == 1) + } + + @Test func renders_the_right_output_when_no_spinner_and_success() async throws { + // Given + let standardPipelines = StandardPipelines() + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: false, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: terminal, + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "ℹ︎ Loading project graph") + #expect(renders.popLast() == "ℹ︎ Loading project at path Project/") + #expect(renders.popLast()?.range(of: "✔︎ Project graph loaded \\[.*s\\]", options: .regularExpression) != nil) + #expect(spinner.stoppedCalls == 1) + } + + @Test func renders_the_right_output_when_no_spinner_and_failure() async throws { + // Given + let standardPipelines = StandardPipelines() + let error = TestError.loadError + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: false, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: terminal, + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "ℹ︎ Loading project graph") + #expect(renders.popLast()?.range(of: "⨯ Failed to load the project graph \\[.*s\\]", options: .regularExpression) != nil) + } +} diff --git a/Tests/NooraTests/Mocks/MockSpinner.swift b/Tests/NooraTests/Mocks/MockSpinner.swift new file mode 100644 index 0000000..269be71 --- /dev/null +++ b/Tests/NooraTests/Mocks/MockSpinner.swift @@ -0,0 +1,12 @@ +@testable import Noora + +class MockSpinner: Spinning { + func spin(_ block: @escaping (String) -> Void) { + block("⠋") + } + + var stoppedCalls: UInt = 0 + func stop() { + stoppedCalls += 1 + } +} From da932b489cddcb765a2051f675b980dc5e08cc0e Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 26 Dec 2024 16:46:11 +0100 Subject: [PATCH 04/12] Implement interactive and non-interactive execution for ProgressStep; enhance test coverage for both scenarios --- Sources/Noora/Components/ProgressStep.swift | 46 +++++++++- .../Components/ProgressStepTests.swift | 85 ++++++++++++++++--- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift index 7a1f8c8..4f0a740 100644 --- a/Sources/Noora/Components/ProgressStep.swift +++ b/Sources/Noora/Components/ProgressStep.swift @@ -40,6 +40,50 @@ class ProgressStep { } func run() async throws { + if terminal.isInteractive { + try await runInteractive() + } else { + try await runNonInteractive() + } + } + + func runNonInteractive() async throws { + /// ℹ︎ + let start = DispatchTime.now() + + var _error: Error? + + do { + standardPipelines.output.write(content: "\("ℹ︎".hexIfColoredTerminal(theme.primary, terminal)) \(message)\n") + + try await action { progressMessage in + self.standardPipelines.output + .write(content: " \(progressMessage.hexIfColoredTerminal(self.theme.muted, self.terminal))\n") + } + } catch { + _error = error + } + + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) + + if _error != nil { + standardPipelines.error + .write( + content: " \("⨯".hexIfColoredTerminal(theme.danger, terminal)) \((errorMessage ?? message).hexIfColoredTerminal(theme.muted, terminal)) \(timeString)\n" + ) + } else { + let message = ProgressStep + .completionMessage(successMessage ?? message, timeString: timeString, theme: theme, terminal: terminal) + standardPipelines.output.write(content: " \(message)\n") + } + + if let _error { + throw _error + } + } + + func runInteractive() async throws { let start = DispatchTime.now() defer { @@ -75,7 +119,7 @@ class ProgressStep { if _error != nil { renderer.render( "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", - standardPipeline: standardPipelines.output + standardPipeline: standardPipelines.error ) } else { renderer.render( diff --git a/Tests/NooraTests/Components/ProgressStepTests.swift b/Tests/NooraTests/Components/ProgressStepTests.swift index 0088bcd..0bcc0dc 100644 --- a/Tests/NooraTests/Components/ProgressStepTests.swift +++ b/Tests/NooraTests/Components/ProgressStepTests.swift @@ -8,10 +8,76 @@ struct ProgressStepTests { } let renderer = MockRenderer() - let terminal = MockTerminal() let spinner = MockSpinner() - @Test func renders_the_right_output_when_spinner_and_success() async throws { + @Test func renders_the_right_output_when_success_and_non_interactive_terminal() async throws { + // Given + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: false), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + #expect(standardOutput.writtenContent.contains(""" + ℹ︎ Loading project graph + Loading project at path Project/ + ✔︎ Project graph loaded + """) == true) + } + + @Test func renders_the_right_output_when_failure_and_non_interactive_terminal() async throws { + // Given + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + let error = TestError.loadError + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: false), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + print(standardError.writtenContent) + #expect(standardOutput.writtenContent.contains(""" + ℹ︎ Loading project graph + """) == true) + #expect(standardError.writtenContent.contains(""" + ⨯ Failed to load the project graph [0.0s] + """) == true) + } + + @Test func renders_the_right_output_when_spinner_and_success_and_interactive_terminal() async throws { // Given let standardPipelines = StandardPipelines() @@ -24,7 +90,7 @@ struct ProgressStepTests { reportProgress("Loading project at path Project/") }, theme: Theme.test(), - terminal: terminal, + terminal: MockTerminal(isInteractive: true), renderer: renderer, standardPipelines: standardPipelines, spinner: spinner @@ -42,7 +108,7 @@ struct ProgressStepTests { #expect(spinner.stoppedCalls == 1) } - @Test func renders_the_right_output_when_spinner_and_failure() async throws { + @Test func renders_the_right_output_when_spinner_and_failure_and_interactive_terminal() async throws { // Given let standardPipelines = StandardPipelines() let error = TestError.loadError @@ -55,7 +121,7 @@ struct ProgressStepTests { throw error }, theme: Theme.test(), - terminal: terminal, + terminal: MockTerminal(isInteractive: true), renderer: renderer, standardPipelines: standardPipelines, spinner: spinner @@ -72,7 +138,7 @@ struct ProgressStepTests { #expect(spinner.stoppedCalls == 1) } - @Test func renders_the_right_output_when_no_spinner_and_success() async throws { + @Test func renders_the_right_output_when_no_spinner_and_success_and_interactive_terminal() async throws { // Given let standardPipelines = StandardPipelines() @@ -85,7 +151,7 @@ struct ProgressStepTests { reportProgress("Loading project at path Project/") }, theme: Theme.test(), - terminal: terminal, + terminal: MockTerminal(isInteractive: true), renderer: renderer, standardPipelines: standardPipelines, spinner: spinner @@ -99,10 +165,9 @@ struct ProgressStepTests { #expect(renders.popLast() == "ℹ︎ Loading project graph") #expect(renders.popLast() == "ℹ︎ Loading project at path Project/") #expect(renders.popLast()?.range(of: "✔︎ Project graph loaded \\[.*s\\]", options: .regularExpression) != nil) - #expect(spinner.stoppedCalls == 1) } - @Test func renders_the_right_output_when_no_spinner_and_failure() async throws { + @Test func renders_the_right_output_when_no_spinner_and_failure_and_interactive_terminal() async throws { // Given let standardPipelines = StandardPipelines() let error = TestError.loadError @@ -115,7 +180,7 @@ struct ProgressStepTests { throw error }, theme: Theme.test(), - terminal: terminal, + terminal: MockTerminal(isInteractive: true), renderer: renderer, standardPipelines: standardPipelines, spinner: spinner From a14a50c7edb7bf73ffeb7b4d099b92bc594788c2 Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 26 Dec 2024 16:58:45 +0100 Subject: [PATCH 05/12] Enhance ProgressStep component with error handling and documentation updates; add new progress step documentation and demo GIFs --- Sources/Noora/Components/ProgressStep.swift | 4 ++ .../Commands/ProgressStepCommand.swift | 2 +- docs/.vitepress/config.mjs | 10 ++++ docs/content/components/progress/step.md | 48 ++++++++++++++++++ .../components/progress/step/interactive.gif | Bin 0 -> 10320 bytes .../progress/step/non-interactive.gif | Bin 0 -> 9133 bytes 6 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 docs/content/components/progress/step.md create mode 100644 docs/content/public/components/progress/step/interactive.gif create mode 100644 docs/content/public/components/progress/step/non-interactive.gif diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift index 4f0a740..0539634 100644 --- a/Sources/Noora/Components/ProgressStep.swift +++ b/Sources/Noora/Components/ProgressStep.swift @@ -51,6 +51,7 @@ class ProgressStep { /// ℹ︎ let start = DispatchTime.now() + // swiftlint:disable:next identifier_name var _error: Error? do { @@ -78,6 +79,7 @@ class ProgressStep { standardPipelines.output.write(content: " \(message)\n") } + // swiftlint:disable:next identifier_name if let _error { throw _error } @@ -102,6 +104,7 @@ class ProgressStep { } } + // swiftlint:disable:next identifier_name var _error: Error? do { render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") @@ -129,6 +132,7 @@ class ProgressStep { ) } + // swiftlint:disable:next identifier_name if let _error { throw _error } diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift index 30a37ac..e329ea3 100644 --- a/Sources/examples-cli/Commands/ProgressStepCommand.swift +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -24,7 +24,7 @@ struct ProgressStepCommand: AsyncParsableCommand { sleep(2) } try await Noora().progressStep( - message: "Generating Xcode projects and workspsace", + message: "Generating Xcode projects and workspace", successMessage: "Xcode projects and workspace generated", errorMessage: "Failed to generate Xcode workspace and projects" ) { _ in diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index ff34743..6550e8b 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -62,6 +62,16 @@ export default defineConfig({ }, ], }, + { + text: "Progress", + collapsed: true, + items: [ + { + text: "Step", + link: "/components/progress/step", + }, + ], + }, { text: "Alerts", collapsed: true, diff --git a/docs/content/components/progress/step.md b/docs/content/components/progress/step.md new file mode 100644 index 0000000..528430e --- /dev/null +++ b/docs/content/components/progress/step.md @@ -0,0 +1,48 @@ +--- +title: Step +titleTemplate: ":title · Progress · Noora · Tuist" +description: A component to represent a progress step +--- + +# Progress step + +This component represents a step in the execution of a command showing the time it took to complete. + +| Property | Value | +| --- | --- | +| Interactivity | Non-required. The component supports non-interactive mode. | + +## Demo + +### Interactive + +![It shows the execution of a command with an interactive progress step](/components/progress/step/interactive.gif) + +### Non-interactive + +![It shows the execution of a command with a non-interactive progress step](/components/progress/step/non-interactive.gif) + +## API + +### Example + +```swift +try await Noora().progressStep( + message: "Processing the graph", + successMessage: "Project graph processed", + errorMessage: "Failed to process the project graph" +) { _progress in + // _progress can be used to report progress + try await doSomething() +} +``` + +### Options + +| Attribute | Description | Required | Default value | +| --- | --- | --- | --- | +| `message` | The message to show to the user | Yes | | +| `successMessage` | The message to show to the user when the step is successful | No | | +| `errorMessage` | The message to show to the user when the step fails | No | | +| `showSpinner` | Whether to show a spinner | No | `true` | +| `action` | The action to execute | Yes | | \ No newline at end of file diff --git a/docs/content/public/components/progress/step/interactive.gif b/docs/content/public/components/progress/step/interactive.gif new file mode 100644 index 0000000000000000000000000000000000000000..40d20bd315664fe5b7b655d893745bcc15a3146d GIT binary patch literal 10320 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 O1ciR;q9DM4>Hh&DDTHJI literal 0 HcmV?d00001 diff --git a/docs/content/public/components/progress/step/non-interactive.gif b/docs/content/public/components/progress/step/non-interactive.gif new file mode 100644 index 0000000000000000000000000000000000000000..3763dde622250e41315a53c9122db7dbbad723c8 GIT binary patch literal 9133 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 e`}=Pj@E;)S%7ab=!T< Date: Thu, 26 Dec 2024 17:00:39 +0100 Subject: [PATCH 06/12] Fix error message formatting in ProgressStepTests to improve clarity --- Tests/NooraTests/Components/ProgressStepTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NooraTests/Components/ProgressStepTests.swift b/Tests/NooraTests/Components/ProgressStepTests.swift index 0bcc0dc..04a977c 100644 --- a/Tests/NooraTests/Components/ProgressStepTests.swift +++ b/Tests/NooraTests/Components/ProgressStepTests.swift @@ -73,7 +73,7 @@ struct ProgressStepTests { ℹ︎ Loading project graph """) == true) #expect(standardError.writtenContent.contains(""" - ⨯ Failed to load the project graph [0.0s] + ⨯ Failed to load the project graph """) == true) } From 581e46298fcbaa61419954ef62378e078f8ec044 Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 26 Dec 2024 17:02:21 +0100 Subject: [PATCH 07/12] Update GitHub Actions workflow to disable commit title matching in conventional PR checks --- .github/workflows/conventional-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conventional-pr.yml b/.github/workflows/conventional-pr.yml index ceceb4c..0012e9d 100644 --- a/.github/workflows/conventional-pr.yml +++ b/.github/workflows/conventional-pr.yml @@ -17,4 +17,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - commitTitleMatch: "false" + commitTitleMatch: false From 954ab981f359a1674bd8a36c47fa08652f23299d Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 26 Dec 2024 17:03:22 +0100 Subject: [PATCH 08/12] Refactor ProgressStepCommand to use async sleep for improved responsiveness --- Sources/examples-cli/Commands/ProgressStepCommand.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift index e329ea3..3fa609b 100644 --- a/Sources/examples-cli/Commands/ProgressStepCommand.swift +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -14,21 +14,21 @@ struct ProgressStepCommand: AsyncParsableCommand { successMessage: "Manifests loaded", errorMessage: "Failed to load manifests" ) { _ in - sleep(2) + try await Task.sleep(nanoseconds: 2_000_000_000) } try await Noora().progressStep( message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph" ) { _ in - sleep(2) + try await Task.sleep(nanoseconds: 2_000_000_000) } try await Noora().progressStep( message: "Generating Xcode projects and workspace", successMessage: "Xcode projects and workspace generated", errorMessage: "Failed to generate Xcode workspace and projects" ) { _ in - sleep(2) + try await Task.sleep(nanoseconds: 2_000_000_000) } } } From 88cef5d3ae2c5e7aa4b643d1d5fd7e38a18e5025 Mon Sep 17 00:00:00 2001 From: Pedro Date: Tue, 28 Jan 2025 16:32:05 +0100 Subject: [PATCH 09/12] Address comments --- Package.resolved | 27 +++++++ Sources/Noora/Components/ProgressStep.swift | 84 +++++++++------------ docs/content/components/progress/step.md | 14 +++- 3 files changed, 72 insertions(+), 53 deletions(-) diff --git a/Package.resolved b/Package.resolved index 10de444..27c8a09 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "asynchrone", + "kind" : "remoteSourceControl", + "location" : "https://github.com/reddavis/Asynchrone", + "state" : { + "revision" : "1ddfcd3bc93277f68dffb793fc60001902f2517b", + "version" : "0.22.0" + } + }, + { + "identity" : "combinex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cx-org/CombineX", + "state" : { + "revision" : "98096c6b2a51481cb6e4bae8da0a808d8cab09a1", + "version" : "0.4.0" + } + }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -17,6 +35,15 @@ "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "3e95ba32cd1b4c877f6163e8eea54afc4e63bf9f", + "version" : "0.0.3" + } } ], "version" : 2 diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift index 0539634..039b512 100644 --- a/Sources/Noora/Components/ProgressStep.swift +++ b/Sources/Noora/Components/ProgressStep.swift @@ -1,7 +1,7 @@ import Foundation import Rainbow -class ProgressStep { +struct ProgressStep { // MARK: - Attributes let message: String @@ -13,7 +13,7 @@ class ProgressStep { let terminal: Terminaling let renderer: Rendering let standardPipelines: StandardPipelines - var spinner: Spinning + let spinner: Spinning init( message: String, @@ -48,40 +48,30 @@ class ProgressStep { } func runNonInteractive() async throws { - /// ℹ︎ let start = DispatchTime.now() - // swiftlint:disable:next identifier_name - var _error: Error? - do { standardPipelines.output.write(content: "\("ℹ︎".hexIfColoredTerminal(theme.primary, terminal)) \(message)\n") try await action { progressMessage in - self.standardPipelines.output - .write(content: " \(progressMessage.hexIfColoredTerminal(self.theme.muted, self.terminal))\n") + standardPipelines.output + .write(content: " \(progressMessage.hexIfColoredTerminal(theme.muted, terminal))\n") } - } catch { - _error = error - } - let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 - let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) - - if _error != nil { + let message = ProgressStep + .completionMessage( + successMessage ?? message, + timeString: timeString(start: start), + theme: theme, + terminal: terminal + ) + standardPipelines.output.write(content: " \(message)\n") + } catch { standardPipelines.error .write( - content: " \("⨯".hexIfColoredTerminal(theme.danger, terminal)) \((errorMessage ?? message).hexIfColoredTerminal(theme.muted, terminal)) \(timeString)\n" + content: " \("⨯".hexIfColoredTerminal(theme.danger, terminal)) \((errorMessage ?? message).hexIfColoredTerminal(theme.muted, terminal)) \(timeString(start: start))\n" ) - } else { - let message = ProgressStep - .completionMessage(successMessage ?? message, timeString: timeString, theme: theme, terminal: terminal) - standardPipelines.output.write(content: " \(message)\n") - } - - // swiftlint:disable:next identifier_name - if let _error { - throw _error + throw error } } @@ -100,48 +90,39 @@ class ProgressStep { if showSpinner { spinner.spin { icon in spinnerIcon = icon - self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") } } // swiftlint:disable:next identifier_name - var _error: Error? do { render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") try await action { progressMessage in lastMessage = progressMessage - self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") } - } catch { - _error = error - } - - let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 - let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) - - if _error != nil { - renderer.render( - "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", - standardPipeline: standardPipelines.error - ) - } else { renderer.render( ProgressStep - .completionMessage(successMessage ?? message, timeString: timeString, theme: theme, terminal: terminal), + .completionMessage( + successMessage ?? message, + timeString: timeString(start: start), + theme: theme, + terminal: terminal + ), standardPipeline: standardPipelines.output ) - } + } catch { + renderer.render( + "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString(start: start))", + standardPipeline: standardPipelines.error + ) - // swiftlint:disable:next identifier_name - if let _error { - throw _error + throw error } } - // MARK: - Private - static func completionMessage(_ message: String, timeString: String? = nil, theme: Theme, terminal: Terminaling) -> String { - "\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(message)\(timeString != nil ? " \(timeString!)" : "")" + "\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(message)\(" \(timeString ?? "")")" } private func render(message: String, icon: String) { @@ -150,4 +131,9 @@ class ProgressStep { standardPipeline: standardPipelines.output ) } + + private func timeString(start: DispatchTime) -> String { + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + return "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) + } } diff --git a/docs/content/components/progress/step.md b/docs/content/components/progress/step.md index 528430e..a80d053 100644 --- a/docs/content/components/progress/step.md +++ b/docs/content/components/progress/step.md @@ -31,9 +31,15 @@ try await Noora().progressStep( message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph" -) { _progress in - // _progress can be used to report progress - try await doSomething() +) { updateMessage in + // An example of an asynchronous task. + let graph = try await loadGraph() + + // You can use updateMessage to update the progress step message. + updateMessage("Analyzing the graph") + + // Another asynchronous task. + try await analyzeGraph(graph) } ``` @@ -45,4 +51,4 @@ try await Noora().progressStep( | `successMessage` | The message to show to the user when the step is successful | No | | | `errorMessage` | The message to show to the user when the step fails | No | | | `showSpinner` | Whether to show a spinner | No | `true` | -| `action` | The action to execute | Yes | | \ No newline at end of file +| `action` | The action to execute | Yes | | From 079945a9cfca414ca63547ca8d207181f750ebc4 Mon Sep 17 00:00:00 2001 From: Pedro Date: Tue, 28 Jan 2025 17:28:41 +0100 Subject: [PATCH 10/12] Fix tests --- Tests/NooraTests/Components/SingleChoicePromptTests.swift | 2 +- Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/NooraTests/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index ab69c47..5ce5c49 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -66,7 +66,7 @@ struct SingleChoicePromptTests { ↑/↓/k/j up/down • enter confirm """) #expect(renders.popLast() == """ - ✔︎ Integration: option1 + ✔︎ Integration: option1 """) } } diff --git a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift index 2fd9bd4..0e0fec3 100644 --- a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift +++ b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift @@ -46,7 +46,7 @@ struct YesOrNoChoicePromptTests { ←/→/h/l left/right • enter confirm """) #expect(renders.popLast() == """ - ✔︎ Authentication: Yes + ✔︎ Authentication: Yes """) } } From 1362c7b145302fad1e932d6d7df58a170d2c0efb Mon Sep 17 00:00:00 2001 From: Pedro Date: Wed, 29 Jan 2025 13:37:16 +0100 Subject: [PATCH 11/12] Include `progressStep` in the Noorable protocol --- Sources/Noora/Noora.swift | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index ff66153..3611b1c 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -111,6 +111,32 @@ public protocol Noorable { /// - Parameters: /// - alerts: The warning messages. func warning(_ alerts: WarningAlert...) + + /// Shows a progress step. + /// - Parameters: + /// - message: The message that represents "what's being done" + /// - action: The asynchronous task to run. The caller can use the argument that the function takes to update the step + /// message. + func progressStep( + message: String, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws + + /// Shows a progress step. + /// - Parameters: + /// - message: The message that represents "what's being done" + /// - successMessage: The message that the step gets updated to when the action completes. + /// - errorMessage: The message that the step gets updated to when the action errors. + /// - showSpinner: True to show a spinner. + /// - action: The asynchronous task to run. The caller can use the argument that the function takes to update the step + /// message. + func progressStep( + message: String, + successMessage: String?, + errorMessage: String?, + showSpinner: Bool, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws } public struct Noora: Noorable { @@ -201,6 +227,10 @@ public struct Noora: Noorable { ).run() } + public func progressStep(message: String, action: @escaping ((String) -> Void) async throws -> Void) async throws { + try await progressStep(message: message, successMessage: nil, errorMessage: nil, showSpinner: true, action: action) + } + public func progressStep( message: String, successMessage: String? = nil, From b20f4d612c2af978ea561557527c0aba3d663aac Mon Sep 17 00:00:00 2001 From: Pedro Date: Wed, 29 Jan 2025 13:41:38 +0100 Subject: [PATCH 12/12] Update Package.resolved --- Package.resolved | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/Package.resolved b/Package.resolved index 27c8a09..10de444 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,23 +1,5 @@ { "pins" : [ - { - "identity" : "asynchrone", - "kind" : "remoteSourceControl", - "location" : "https://github.com/reddavis/Asynchrone", - "state" : { - "revision" : "1ddfcd3bc93277f68dffb793fc60001902f2517b", - "version" : "0.22.0" - } - }, - { - "identity" : "combinex", - "kind" : "remoteSourceControl", - "location" : "https://github.com/cx-org/CombineX", - "state" : { - "revision" : "98096c6b2a51481cb6e4bae8da0a808d8cab09a1", - "version" : "0.4.0" - } - }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -35,15 +17,6 @@ "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "3e95ba32cd1b4c877f6163e8eea54afc4e63bf9f", - "version" : "0.0.3" - } } ], "version" : 2