From 1511fabf0417a00f56c49d21e61289c5ecb8bac7 Mon Sep 17 00:00:00 2001 From: knQzx <75641500+knQzx@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:32:26 +0200 Subject: [PATCH 1/3] fall back to simple text output when stdout is not a tty --- .../Flags+ProgressConfig.swift | 26 ++++++++++++++++--- .../ContainerAPIService/Client/Flags.swift | 5 ++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Sources/ContainerCommands/Flags+ProgressConfig.swift b/Sources/ContainerCommands/Flags+ProgressConfig.swift index e876c94cf..0b176e523 100644 --- a/Sources/ContainerCommands/Flags+ProgressConfig.swift +++ b/Sources/ContainerCommands/Flags+ProgressConfig.swift @@ -15,15 +15,32 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import Foundation import TerminalProgress extension Flags.Progress { + /// Resolves `.auto` into `.ansi` or `.plain` based on the terminal. + /// + /// When the progress type is `.auto`, the terminal file handle is checked + /// with `isatty`. If the output is a TTY, `.ansi` is used; otherwise + /// `.plain` is selected so that piped or redirected output still shows + /// simple line-by-line status text. + private func resolvedProgress(terminal: FileHandle = .standardError) -> ProgressType { + switch progress { + case .auto: + return isatty(terminal.fileDescriptor) == 1 ? .ansi : .plain + case .none, .ansi, .plain, .color: + return progress + } + } + /// Creates a `ProgressConfig` based on the selected progress type. /// /// For `.none`, progress updates are disabled. For `.ansi`, the given parameters /// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish) /// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches /// `.ansi` but the output mode is set to `.color` to enable color-coded output. + /// For `.auto`, the type is resolved by checking whether stderr is a TTY. func makeConfig( description: String = "", itemsName: String = "it", @@ -33,13 +50,14 @@ extension Flags.Progress { ignoreSmallSize: Bool = false, totalTasks: Int? = nil ) throws -> ProgressConfig { - switch progress { + let resolved = resolvedProgress() + switch resolved { case .none: return try ProgressConfig(disableProgressUpdates: true) case .ansi, .plain, .color: - let isPlain = progress == .plain + let isPlain = resolved == .plain let outputMode: ProgressConfig.OutputMode - switch progress { + switch resolved { case .plain: outputMode = .plain case .color: outputMode = .color default: outputMode = .ansi @@ -56,6 +74,8 @@ extension Flags.Progress { clearOnFinish: !isPlain, outputMode: outputMode ) + case .auto: + fatalError("unreachable: .auto should have been resolved") } } } diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 04e5467c9..6a964e8cb 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -351,14 +351,15 @@ public struct Flags { } public enum ProgressType: String, ExpressibleByArgument { + case auto case none case ansi case plain case color } - @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type")) - public var progress: ProgressType = .ansi + @Option(name: .long, help: ArgumentHelp("Progress type (format: auto|none|ansi|plain|color)", valueName: "type")) + public var progress: ProgressType = .auto } public struct ImageFetch: ParsableArguments { From 4c4190808165bb3e788f25a97e19520dee4cc36b Mon Sep 17 00:00:00 2001 From: knQzx <75641500+knQzx@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:47:40 +0200 Subject: [PATCH 2/3] add integration test for progress auto fallback --- .../Images/TestCLIProgressAuto.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift diff --git a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift new file mode 100644 index 000000000..06b07df14 --- /dev/null +++ b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +class TestCLIProgressAuto: CLITest { + @Test func testAutoProgressFallsBackToPlainWhenPiped() throws { + do { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "auto", + alpine, + ]) + #expect(status == 0, "image pull should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") + } catch { + Issue.record("failed to test auto progress: \(error)") + return + } + } + + @Test func testExplicitPlainProgress() throws { + do { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "plain", + alpine, + ]) + #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") + } catch { + Issue.record("failed to test plain progress: \(error)") + return + } + } + + @Test func testNoneProgressSuppressesOutput() throws { + do { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "none", + alpine, + ]) + #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") + } catch { + Issue.record("failed to test none progress: \(error)") + return + } + } +} From 0b5d24fde44ee15cdc027598af7f0e6001976621 Mon Sep 17 00:00:00 2001 From: knQzx <75641500+knQzx@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:37:12 +0200 Subject: [PATCH 3/3] address review feedback --- .../Flags+ProgressConfig.swift | 11 +-- .../Images/TestCLIProgressAuto.swift | 85 +++++++++---------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/Sources/ContainerCommands/Flags+ProgressConfig.swift b/Sources/ContainerCommands/Flags+ProgressConfig.swift index 0b176e523..2e64963c2 100644 --- a/Sources/ContainerCommands/Flags+ProgressConfig.swift +++ b/Sources/ContainerCommands/Flags+ProgressConfig.swift @@ -19,16 +19,11 @@ import Foundation import TerminalProgress extension Flags.Progress { - /// Resolves `.auto` into `.ansi` or `.plain` based on the terminal. - /// - /// When the progress type is `.auto`, the terminal file handle is checked - /// with `isatty`. If the output is a TTY, `.ansi` is used; otherwise - /// `.plain` is selected so that piped or redirected output still shows - /// simple line-by-line status text. - private func resolvedProgress(terminal: FileHandle = .standardError) -> ProgressType { + /// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY. + private func resolvedProgress() -> ProgressType { switch progress { case .auto: - return isatty(terminal.fileDescriptor) == 1 ? .ansi : .plain + return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain case .none, .ansi, .plain, .color: return progress } diff --git a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift index 06b07df14..fe326faef 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift @@ -19,55 +19,52 @@ import Testing class TestCLIProgressAuto: CLITest { @Test func testAutoProgressFallsBackToPlainWhenPiped() throws { - do { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "auto", - alpine, - ]) - #expect(status == 0, "image pull should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") - } catch { - Issue.record("failed to test auto progress: \(error)") - return - } + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "auto", + alpine, + ]) + #expect(status == 0, "image pull should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") } @Test func testExplicitPlainProgress() throws { - do { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "plain", - alpine, - ]) - #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") - } catch { - Issue.record("failed to test plain progress: \(error)") - return - } + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "plain", + alpine, + ]) + #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") + } + + @Test func testExplicitAnsiProgress() throws { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "ansi", + alpine, + ]) + #expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected ansi progress output on stderr") } @Test func testNoneProgressSuppressesOutput() throws { - do { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "none", - alpine, - ]) - #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") - } catch { - Issue.record("failed to test none progress: \(error)") - return - } + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "none", + alpine, + ]) + #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") } }