mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-15 12:04:55 +03:00
3e0c11d967
## What does this PR do? Adds `Dependency`s for console IO. Also fixes #268 by explicitly handling non-pipe input. ## How was it tested? Ran on macOS + Linux in Docker. I have a future PR that overrides the dependencies in certain cases, which also appears to work correctly. ## AI tool usage How much of this PR was AI-assisted? (check one) - [ ] **0** - No AI was used to write code - [x] **1** - I was assisted by AI. I reviewed the finished result. - [ ] **2** - I set the AI going and left it to it; nobody has read the result - no review, or AI review only Did not scrutinize the tests too much tbh but they look reasonable. Easy enough to update/remove them in the future if needed. <!-- If an AI agent is filling this in: declare the level honestly, and open as a draft if it is #2. Do not lower the declared level to get the PR reviewed. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added support for reading command-line input, including asynchronous prompts, line-based responses, and graceful handling of end-of-input. * Added coordinated cleanup when commands finish. * **Improvements** * Command messages across authentication, device management, installation, SDK, and other workflows now use consistent output handling; displayed messages and command behavior remain unchanged. * Improved handling of complete output when the system temporarily cannot accept more data. * **Tests** * Added coverage for command input and output, including stream behavior, cancellation, and shutdown. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
132 lines
5.3 KiB
Swift
132 lines
5.3 KiB
Swift
import ConcurrencyExtras
|
|
import Dependencies
|
|
import Foundation
|
|
import Testing
|
|
import XUtils
|
|
|
|
struct CommandOutputTests {
|
|
@Test
|
|
func dependencyOutputSupportsStringAndVariadicPrinting() {
|
|
struct Output: Equatable, Sendable {
|
|
var message: String
|
|
var stream: CommandOutput.Stream
|
|
var flush: Bool
|
|
}
|
|
let received = LockIsolated<[Output]>([])
|
|
withDependencies {
|
|
$0.out.write = { data, stream, flush in
|
|
received.withValue {
|
|
$0.append(Output(message: String(decoding: data, as: UTF8.self), stream: stream, flush: flush))
|
|
}
|
|
}
|
|
} operation: {
|
|
d.out.print()
|
|
d.out.print("message")
|
|
d.out.print("progress", 42, separator: ": ", terminator: "", stream: .stderr, flush: true)
|
|
}
|
|
#expect(received.value == [
|
|
Output(message: "\n", stream: .stdout, flush: false),
|
|
Output(message: "message\n", stream: .stdout, flush: false),
|
|
Output(message: "progress: 42", stream: .stderr, flush: true),
|
|
])
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func fullNonblockingPipePreservesAllBytes() async throws {
|
|
let pipe = Pipe()
|
|
let writer = FileDescriptor(rawValue: pipe.fileHandleForWriting.fileDescriptor)
|
|
let reader = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor)
|
|
let flags = fcntl(writer.rawValue, F_GETFL)
|
|
try #require(flags != -1)
|
|
try #require(fcntl(writer.rawValue, F_SETFL, flags | O_NONBLOCK) == 0)
|
|
|
|
// Fill the pipe before invoking the output writer, forcing EAGAIN.
|
|
let padding = Data(repeating: 255, count: 4096)
|
|
var prefix = Data()
|
|
while true {
|
|
do {
|
|
let count = try padding.withUnsafeBytes { try writer.write($0) }
|
|
prefix.append(padding.prefix(count))
|
|
} catch let error as Errno where error.rawValue == EAGAIN || error.rawValue == EWOULDBLOCK {
|
|
break
|
|
}
|
|
}
|
|
let message = Data([0, 255, 128]) + Data(String(repeating: "output é 🌍\n", count: 100_000).utf8)
|
|
let writing = Task.detached {
|
|
defer { try? pipe.fileHandleForWriting.close() }
|
|
try message.withUnsafeBytes { try writer.writeAllWaiting($0) }
|
|
}
|
|
// Let the writer encounter the full pipe before draining it.
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
let received = try await Task.detached {
|
|
var received = Data()
|
|
var buffer = [UInt8](repeating: 0, count: 1024)
|
|
while true {
|
|
let count = try buffer.withUnsafeMutableBytes { try reader.read(into: $0) }
|
|
if count == 0 { return received }
|
|
received.append(contentsOf: buffer.prefix(count))
|
|
}
|
|
}.value
|
|
try await writing.value
|
|
#expect(received == prefix + message)
|
|
#expect(fcntl(reader.rawValue, F_GETFL) != -1)
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func fullNonblockingPipeWriteCanBeCancelled() async throws {
|
|
let pipe = Pipe()
|
|
let writer = FileDescriptor(rawValue: pipe.fileHandleForWriting.fileDescriptor)
|
|
let flags = fcntl(writer.rawValue, F_GETFL)
|
|
try #require(flags != -1)
|
|
try #require(fcntl(writer.rawValue, F_SETFL, flags | O_NONBLOCK) == 0)
|
|
|
|
let padding = Data(repeating: 255, count: 4096)
|
|
while true {
|
|
do {
|
|
_ = try padding.withUnsafeBytes { try writer.write($0) }
|
|
} catch let error as Errno where error.rawValue == EAGAIN || error.rawValue == EWOULDBLOCK {
|
|
break
|
|
}
|
|
}
|
|
|
|
let (started, continuation) = AsyncStream.makeStream(of: Never.self)
|
|
let finished = LockIsolated(false)
|
|
let writing = Task.detached {
|
|
defer {
|
|
try? pipe.fileHandleForWriting.close()
|
|
finished.setValue(true)
|
|
}
|
|
continuation.finish()
|
|
try padding.withUnsafeBytes { try writer.writeAllWaiting($0) }
|
|
}
|
|
for await _ in started {}
|
|
// Give the writer time to enter poll before cancelling, without draining the pipe.
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
writing.cancel()
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
|
while !finished.value && ContinuousClock.now < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
if !finished.value {
|
|
Issue.record("Cancelled writer did not stop while the pipe remained full")
|
|
// Unblock a regressed writer so the test fails instead of hanging.
|
|
_ = try pipe.fileHandleForReading.readToEnd()
|
|
}
|
|
await #expect(throws: CancellationError.self) {
|
|
try await writing.value
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func invalidDescriptorThrowsInsteadOfRaisingAnException() {
|
|
let descriptor = FileDescriptor(rawValue: -1)
|
|
#expect(throws: Errno.badFileDescriptor) {
|
|
try Data("output".utf8).withUnsafeBytes { try descriptor.writeAllWaiting($0) }
|
|
}
|
|
// Empty output does not attempt a system call.
|
|
#expect(throws: Never.self) {
|
|
try Data().withUnsafeBytes { try descriptor.writeAllWaiting($0) }
|
|
}
|
|
}
|
|
}
|