diff --git a/.ai/memory.md b/.ai/memory.md index 62b4de97..f1e2362d 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -550,3 +550,14 @@ - **`XCUIApplication.launch()` does not reliably foreground the app on macOS.** The runner keeps focus, and a backgrounded app's window has an **empty accessibility tree** — so every element query fails with "does not exist" and the only cure is clicking the Dock icon. `BrewApp.launch` calls `activate()` and waits for `.runningForeground`. This is load-bearing, not cosmetic. - **Element-not-found failures carry a diagnosis.** `BrewUITestDiagnostics` reports whether the app is running, foregrounded, has a window, and which identifiers are actually in the tree. "Expected installed.screen to exist within 60s" is true and explains nothing; distinguishing "never opened a window" from "wrong identifier" is the difference between a five-minute fix and a day. - **Assertions gated on a subprocess use the command timeout, not the render timeout.** `DoctorReport.placeholder.isHealthy` is `false`, so while `brew doctor` is in flight the Doctor screen shows the redacted *issues* skeleton and the healthy text does not exist yet. Same for Configuration, whose cards only exist once `brew config` has been parsed. + +## 2026-08-27 — The console assembler keeps a window of revisable rows, not a single line + +- **The bug.** On a multi-cask `brew upgrade` the console repeated the whole download block every tick instead of updating it. Homebrew's parallel download queue (`download_queue.rb`) draws N rows, deliberately leaves the last one **without** a newline, then rewinds with `Tty.move_cursor_up_beginning` → `ESC[F` (CPL). `TerminalLineAssembler` handled `m`/`K`/`G`/`C`/`D` and dropped everything else, so the rewind was lost. Both reported symptoms came from that one gap: the block repeated, *and* the newline-less last row spliced into the next frame's first row. +- **A single line of cells was the wrong shape**, and the old doc comment said so. The fix keeps `windowDepth` rows of history addressable rather than only the row being written. **Depth = the pty's row count**, because that *is* the screen — a terminal cannot address above it. Brew's block is `min(concurrency, Tty.height)` and `Tty.height` reads that same pty (`PseudoTerminal.defaultRows`, 40); default concurrency is `CPU cores × 2`, so 40 always covers it. +- **The non-obvious part is `\n`.** Inside a block it must **step the cursor down** (decrement `rowCursor`, reset column), *not* open a new row — only at `rowCursor == 0` does it commit. Without that the window fills with duplicates and nothing is actually fixed. +- **`windowDepth` is injected, not read.** `TerminalLineAssembler` is in `BrewCore`; `PseudoTerminal` is in `BrewCLI`, which depends on `BrewCore` and not the reverse. `BrewCLI` passes its own value in; `defaultWindowDepth` mirrors it with a comment saying why it can't reference it. +- **Rows carry a serial** so revisions coalesce per row across a chunk even when a commit shifts every position along. Offsets count **from the end**, which is what keeps them valid after `CommandJob.maxOutputLines` trims the front. +- **Over-scroll degrades to appending rather than clamping.** A move above the window is ignored — the pre-window behaviour, which looks wrong but never writes to the *wrong* row. Deliberate escape hatch for sequences this model doesn't cover (`ESC[H`, `ESC[J`, scroll regions, alt screen — brew's download queue uses none of them). +- **Trailing-newline-ness is a property of how a row ended, not of `isComplete`.** Conflating them regressed `BrewCommandServicePseudoTerminalTests`: a child that ends with `printf 'tty'` (no newline) must not gain one in `standardOutput`. `TerminalTranscript` tracks `endsWithNewline` separately so the returned transcript holds settled rows without inventing terminators. +- **Rejected: adopting SwiftTerm.** Its `Terminal` models a fixed grid + scrollback; the console models a variable-length transcript with stable row identities for SwiftUI diffing. Using the engine headless means diffing a grid to infer which rows changed — plausibly more code than this, and it fights `CONVENTIONS.md`'s "add packages sparingly". Revisit only if the app ever needs to run arbitrary interactive commands (a shell, a pager, anything using the alt screen), where hand-rolling stops being viable. diff --git a/Sources/BrewCLI/BrewCommandService.swift b/Sources/BrewCLI/BrewCommandService.swift index 98799bc1..d0b09b14 100644 --- a/Sources/BrewCLI/BrewCommandService.swift +++ b/Sources/BrewCLI/BrewCommandService.swift @@ -138,9 +138,9 @@ private extension BrewCommandService { await withCheckedContinuation { (continuation: CheckedContinuation) in // `read` parks on `poll`; run it on a GCD worker rather than the cooperative pool. DispatchQueue.global(qos: .utility).async { - var transcript = "" + var rows = TerminalTranscript() var undecoded = Data() - var assembler = TerminalLineAssembler() + var assembler = TerminalLineAssembler(windowDepth: Int(PseudoTerminal.defaultRows)) var readFailure: Int32? loop: while true { @@ -148,9 +148,7 @@ private extension BrewCommandService { case let .data(chunk): undecoded.append(chunk) for event in assembler.consume(UTF8StreamDecoder.takeDecodablePrefix(&undecoded)) { - if case let .committed(line) = event { - transcript += line.text + "\n" - } + rows.apply(event) emit(event, sink: sink) } case .timedOut: @@ -167,9 +165,10 @@ private extension BrewCommandService { // Output that ended without a trailing newline is settled by the stream ending. if let trailing = assembler.flush() { - transcript += trailing.text + rows.settle(trailing.text) sink?(BrewCommandOutputLine(stream: .stdout, line: trailing, isComplete: true)) } + var transcript = rows.text // The exit status may still say success, so truncation has to say so itself. if let readFailure { let notice = truncationNotice(errno: readFailure) @@ -193,8 +192,57 @@ private extension BrewCommandService { switch event { case let .committed(line): sink(BrewCommandOutputLine(stream: .stdout, line: line, isComplete: true)) - case let .revised(line): - sink(BrewCommandOutputLine(stream: .stdout, line: line, isComplete: false)) + case let .revised(line, rowOffset): + sink(BrewCommandOutputLine(stream: .stdout, line: line, isComplete: rowOffset > 0, rowOffset: rowOffset)) + } + } +} + +/// Mirrors what the sink is told, so the transcript holds settled rows rather than every frame. +struct TerminalTranscript { + /// `endsWithNewline` is how the row ended, not whether it can still change: output that stopped + /// without a trailing newline must not gain one. + private struct Row { + var text: String + var isComplete: Bool + var endsWithNewline: Bool + } + + private var rows: [Row] = [] + + var text: String { + rows.map { $0.text + ($0.endsWithNewline ? "\n" : "") }.joined() + } + + mutating func apply(_ event: TerminalLineEvent) { + switch event { + case let .committed(line): + put(line.text, isComplete: true, endsWithNewline: true, rowOffset: 0) + case let .revised(line, rowOffset): + put(line.text, isComplete: rowOffset > 0, endsWithNewline: false, rowOffset: rowOffset) + } + } + + /// Settles the trailing row; the stream ending is not itself a newline. + mutating func settle(_ text: String) { + put(text, isComplete: true, endsWithNewline: false, rowOffset: 0) + } + + private mutating func put(_ text: String, isComplete: Bool, endsWithNewline: Bool, rowOffset: Int) { + guard rowOffset == 0 else { + // A row further back has already ended; only its content can change. + let index = rows.count - 1 - rowOffset + guard rows.indices.contains(index) else { + return + } + rows[index].text = text + return + } + let row = Row(text: text, isComplete: isComplete, endsWithNewline: endsWithNewline) + if let last = rows.last, !last.isComplete { + rows[rows.count - 1] = row + } else { + rows.append(row) } } } diff --git a/Sources/BrewCore/Operations/BrewCommandOutputLine.swift b/Sources/BrewCore/Operations/BrewCommandOutputLine.swift index 8021eb47..44a539b6 100644 --- a/Sources/BrewCore/Operations/BrewCommandOutputLine.swift +++ b/Sources/BrewCore/Operations/BrewCommandOutputLine.swift @@ -21,6 +21,9 @@ public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable { /// `false` while the line may still be revised. public let isComplete: Bool + /// Rows back from the end of the buffer this line belongs. Always 0 on the pipe path. + public let rowOffset: Int + public enum Stream: Equatable, Sendable { case stdout case stderr @@ -32,12 +35,14 @@ public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable { timestamp: Date = Date(), id: UUID = UUID(), isComplete: Bool = true, + rowOffset: Int = 0, ) { self.id = id self.stream = stream self.text = text self.timestamp = timestamp self.isComplete = isComplete + self.rowOffset = rowOffset spans = ANSIParser.parse(text) } @@ -48,12 +53,14 @@ public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable { isComplete: Bool, timestamp: Date = Date(), id: UUID = UUID(), + rowOffset: Int = 0, ) { self.id = id self.stream = stream text = line.text self.timestamp = timestamp self.isComplete = isComplete + self.rowOffset = rowOffset spans = line.spans } @@ -68,6 +75,7 @@ public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable { text = other.text timestamp = other.timestamp isComplete = other.isComplete + rowOffset = other.rowOffset spans = other.spans } } diff --git a/Sources/BrewCore/Operations/TerminalLineAssembler.swift b/Sources/BrewCore/Operations/TerminalLineAssembler.swift index bd5b1433..62908e5d 100644 --- a/Sources/BrewCore/Operations/TerminalLineAssembler.swift +++ b/Sources/BrewCore/Operations/TerminalLineAssembler.swift @@ -20,59 +20,69 @@ public struct TerminalLine: Equatable, Sendable { } public enum TerminalLineEvent: Equatable, Sendable { - /// Ended with a newline; will not change again. case committed(TerminalLine) - /// Still being written; may change again before it commits. - case revised(TerminalLine) + /// `rowOffset` counts back from the most recently reported row: 0 is the row still being written. + case revised(TerminalLine, rowOffset: Int) } -/// Applies terminal overwrite semantics to a byte stream. -/// -/// A terminal is not an append-only transcript: a carriage return moves the cursor back to column zero -/// and what follows overwrites what was there. `curl` redraws its progress bar hundreds of times that -/// way, with no newline between redraws, so splitting on newlines alone collapses a whole download into -/// one enormous line holding every intermediate state. -/// -/// Keeping the line as cells with a cursor makes an overwrite replace earlier content. Handles `\r`, -/// `\b`, `\n`, `ESC[K`, the within-line cursor moves `ESC[G`, `ESC[C` and `ESC[D`, and SGR. Cursor -/// movement *between* lines (`ESC[A` and friends, used for multi-line progress blocks) is consumed and -/// ignored: doing it properly needs a screen buffer rather than a line. Style carries across lines, as -/// it does in a real terminal. +/// Applies terminal overwrite semantics to a byte stream: `\r`, `\b`, `\n`, `ESC[K`, the column moves +/// `ESC[G/C/D`, the row moves `ESC[F/A/B`, and SGR. ``windowDepth`` committed rows stay addressable so +/// Homebrew's download block — N rows, last one newline-less, rewound with `ESC[F` — revises in place. +/// Sequences needing a real screen buffer (`ESC[H`, `ESC[J`, scroll regions, alt screen) are ignored. public struct TerminalLineAssembler: Sendable { private struct Cell: Equatable { var character: Character var style: ANSIStyle } + /// `serial` survives the row moving through the window, so revisions coalesce per row. + private struct Row { + var cells: [Cell] = [] + let serial: Int + var wasReported = false + } + private static let escape: Unicode.Scalar = "\u{1B}" /// Well past any real width; caps what a malformed `ESC[999999999C` makes the next write pad. private static let maxColumn = 4096 - private var cells: [Cell] = [] + /// Mirrors `PseudoTerminal.defaultRows`, which lives in `BrewCLI` and cannot be referenced here. + public static let defaultWindowDepth = 40 + + private let windowDepth: Int + /// Committed rows still open to revision, oldest first. + private var window: [Row] = [] + private var pending: Row private var column = 0 + /// 0 targets ``pending``; n targets the nth row back in ``window``. + private var rowCursor = 0 private var style = ANSIStyle.default + private var nextSerial = 1 + /// Serials touched since the last report, so one chunk yields at most one revision per row. + private var dirty: Set = [] - public init() {} + public init(windowDepth: Int = TerminalLineAssembler.defaultWindowDepth) { + self.windowDepth = max(0, windowDepth) + pending = Row(serial: 0) + } public var hasPendingLine: Bool { - !cells.isEmpty + !pending.cells.isEmpty } public var pendingLine: TerminalLine { - TerminalLine(spans: Self.spans(from: cells)) + TerminalLine(spans: Self.spans(from: pending.cells)) } - /// Returns the lines this chunk completed, plus one ``TerminalLineEvent/revised(_:)`` if the - /// in-progress line changed. One revision per chunk rather than per write: a progress bar writes far - /// faster than a UI needs to repaint. + /// One revision per row per chunk rather than per write: a progress bar writes far faster than a UI + /// needs to repaint. public mutating func consume(_ input: String) -> [TerminalLineEvent] { guard !input.isEmpty else { return [] } var events: [TerminalLineEvent] = [] - var pendingChanged = false let scalars = Array(input.unicodeScalars) var index = 0 @@ -81,10 +91,15 @@ public struct TerminalLineAssembler: Sendable { switch scalar { case "\n": - events.append(.committed(TerminalLine(spans: Self.spans(from: cells)))) - cells = [] - column = 0 - pendingChanged = false + if rowCursor > 0 { + // Mid-block: steps down onto the next row rather than opening one. + rowCursor -= 1 + column = 0 + } else { + events.append(.committed(TerminalLine(spans: Self.spans(from: pending.cells)))) + dirty.remove(pending.serial) + commitPending() + } index += 1 case "\r": column = 0 @@ -95,7 +110,7 @@ public struct TerminalLineAssembler: Sendable { case Self.escape: let (next, control) = ANSIParser.scanEscape(scalars, from: index) if let control { - pendingChanged = apply(control) || pendingChanged + apply(control) } index = next default: @@ -105,62 +120,127 @@ public struct TerminalLineAssembler: Sendable { continue } write(Character(scalar)) - pendingChanged = true index += 1 } } - if pendingChanged { - events.append(.revised(TerminalLine(spans: Self.spans(from: cells)))) - } + events += revisionEvents() return events } - /// Commits the in-progress line when the stream ends without a trailing newline. + /// Settles the row still being drawn; rows in the window were already reported. public mutating func flush() -> TerminalLine? { - guard !cells.isEmpty else { + defer { + window = [] + rowCursor = 0 + column = 0 + dirty = [] + } + guard !pending.cells.isEmpty else { return nil } - let line = TerminalLine(spans: Self.spans(from: cells)) - cells = [] - column = 0 + let line = TerminalLine(spans: Self.spans(from: pending.cells)) + pending = Row(serial: nextSerial) + nextSerial += 1 return line } - /// Pads with spaces if the cursor has moved past the end of the line. - private mutating func write(_ character: Character) { - if column > cells.count { - cells.append(contentsOf: repeatElement(Cell(character: " ", style: style), count: column - cells.count)) + // MARK: - Rows + + private mutating func commitPending() { + var committed = pending + committed.wasReported = true + window.append(committed) + if window.count > windowDepth { + window.removeFirst(window.count - windowDepth) + } + pending = Row(serial: nextSerial) + nextSerial += 1 + column = 0 + } + + private mutating func withTargetRow(_ body: (inout Row) -> T) -> T { + let index = window.count - rowCursor + guard rowCursor > 0, window.indices.contains(index) else { + return body(&pending) } - let cell = Cell(character: character, style: style) - if column < cells.count { - cells[column] = cell - } else { - cells.append(cell) + return body(&window[index]) + } + + /// The order `rowOffset` counts in. ``pending`` joins only once reported, or about to be. + private func reportedRowsNewestFirst() -> [Row] { + var rows: [Row] = [] + if pending.wasReported || dirty.contains(pending.serial) { + rows.append(pending) } - column += 1 + rows.append(contentsOf: window.reversed()) + return rows } - /// Returns whether the sequence changed the visible line; moving the cursor never does on its own. - private mutating func apply(_ control: ANSIParser.ControlSequence) -> Bool { + /// Furthest back first, so the update reads top-down. + private mutating func revisionEvents() -> [TerminalLineEvent] { + guard !dirty.isEmpty else { + return [] + } + + var events: [TerminalLineEvent] = [] + for (offset, row) in reportedRowsNewestFirst().enumerated().reversed() where dirty.contains(row.serial) { + events.append(.revised(TerminalLine(spans: Self.spans(from: row.cells)), rowOffset: offset)) + } + if dirty.contains(pending.serial) { + pending.wasReported = true + } + dirty.removeAll(keepingCapacity: true) + return events + } + + // MARK: - Writing + + /// Pads with spaces if the cursor has moved past the end of the row. + private mutating func write(_ character: Character) { + let column = column + let style = style + let serial = withTargetRow { row -> Int in + if column > row.cells.count { + let padding = repeatElement(Cell(character: " ", style: style), count: column - row.cells.count) + row.cells.append(contentsOf: padding) + } + let cell = Cell(character: character, style: style) + if column < row.cells.count { + row.cells[column] = cell + } else { + row.cells.append(cell) + } + return row.serial + } + dirty.insert(serial) + self.column += 1 + } + + private mutating func apply(_ control: ANSIParser.ControlSequence) { switch control.finalByte { case "m": style = ANSIParser.apply(control.parameters, to: style) - return false case "K": - return eraseInLine(mode: Self.parameter(control, default: 0)) + eraseInLine(mode: Self.parameter(control, default: 0)) case "G": // Absolute column, 1-based. Some progress renderers use this where `curl` uses `\r`. move(to: Self.parameter(control, default: 1) - 1) - return false case "C": move(to: column + max(1, Self.parameter(control, default: 1))) - return false case "D": move(to: column - max(1, Self.parameter(control, default: 1))) - return false + case "F": + // CPL: up n rows to column zero — how Homebrew's download block rewinds. + moveRowCursor(by: max(1, Self.parameter(control, default: 1))) + column = 0 + case "A": + // CUU: up n rows, keeping the column. + moveRowCursor(by: max(1, Self.parameter(control, default: 1))) + case "B": + moveRowCursor(by: -max(1, Self.parameter(control, default: 1))) default: - return false + break } } @@ -168,6 +248,19 @@ public struct TerminalLineAssembler: Sendable { column = min(Self.maxColumn, max(0, target)) } + private mutating func moveRowCursor(by rows: Int) { + let target = rowCursor + rows + guard target > 0 else { + rowCursor = 0 + return + } + // Nothing above the window to revise; ignoring degrades to appending rather than a wrong row. + guard target <= window.count else { + return + } + rowCursor = target + } + private static func parameter(_ control: ANSIParser.ControlSequence, default fallback: Int) -> Int { guard let first = control.parameters.split(separator: ";").first, let value = Int(first) else { return fallback @@ -178,31 +271,38 @@ public struct TerminalLineAssembler: Sendable { /// Erase to end of line (0, the default), to the start (1), or all of it (2). Erasing to the start /// blanks cells rather than removing them, so the cursor keeps its column, and blanks take the /// current style, which is what a terminal erases with. - private mutating func eraseInLine(mode: Int) -> Bool { - switch mode { - case 0: - guard column < cells.count else { - return false - } - cells.removeSubrange(column...) - return true - case 1: - let end = min(column + 1, cells.count) - guard end > 0 else { - return false - } - for index in 0 ..< end { - cells[index] = Cell(character: " ", style: style) - } - return true - case 2: - guard !cells.isEmpty else { - return false + private mutating func eraseInLine(mode: Int) { + let column = column + let style = style + let touched = withTargetRow { row -> Int? in + switch mode { + case 0: + guard column < row.cells.count else { + return nil + } + row.cells.removeSubrange(column...) + return row.serial + case 1: + let end = min(column + 1, row.cells.count) + guard end > 0 else { + return nil + } + for index in 0 ..< end { + row.cells[index] = Cell(character: " ", style: style) + } + return row.serial + case 2: + guard !row.cells.isEmpty else { + return nil + } + row.cells = [] + return row.serial + default: + return nil } - cells = [] - return true - default: - return false + } + if let touched { + dirty.insert(touched) } } diff --git a/Sources/BrewRepositoryInterfaces/CommandJob.swift b/Sources/BrewRepositoryInterfaces/CommandJob.swift index 90e9f000..a0eacbb5 100644 --- a/Sources/BrewRepositoryInterfaces/CommandJob.swift +++ b/Sources/BrewRepositoryInterfaces/CommandJob.swift @@ -81,9 +81,18 @@ public final class CommandJob: Identifiable { } } - /// Appends, or replaces the trailing row when it was still being drawn. Revisions keep the row's - /// identity, so a redrawing progress bar animates rather than accumulating hundreds of rows. + /// Appends, or replaces a row the terminal redrew, keeping its identity so the list animates rather + /// than rebuilding. ``BrewCommandOutputLine/rowOffset`` counts back from the end, staying valid after + /// `maxOutputLines` trims the front; an out-of-range offset has scrolled out of reach and is dropped. public func appendOutput(_ line: BrewCommandOutputLine) { + guard line.rowOffset == 0 else { + let index = output.count - 1 - line.rowOffset + guard output.indices.contains(index) else { + return + } + output[index] = line.adoptingIdentity(of: output[index]) + return + } if let last = output.last, !last.isComplete { output[output.count - 1] = line.adoptingIdentity(of: last) } else { diff --git a/Tests/BrewCLITests/TerminalProgressIntegrationTests.swift b/Tests/BrewCLITests/TerminalProgressIntegrationTests.swift index 84c861dd..079fe3d1 100644 --- a/Tests/BrewCLITests/TerminalProgressIntegrationTests.swift +++ b/Tests/BrewCLITests/TerminalProgressIntegrationTests.swift @@ -47,9 +47,60 @@ struct TerminalProgressIntegrationTests { #expect(rows.count == 1 && rows[0].isComplete) } + + /// Homebrew's download block: three rows, the last newline-less, rewound each tick with `ESC[2F`. + private static let downloadBlockScript = """ + for tick in $(seq 1 25); do + printf 'Cask alpha Downloading %s MB\\033[K\\n' $tick + printf 'Cask beta Downloading %s MB\\033[K\\n' $tick + printf 'Cask gamma Downloading %s MB\\033[K' $tick + printf '\\033[2F' + done + printf 'Cask alpha Downloaded\\033[K\\n' + printf 'Cask beta Downloaded\\033[K\\n' + printf 'Cask gamma Downloaded\\033[K\\n' + """ + + @Test func `a redrawn download block settles into one row per cask`() async throws { + let rows = try await runIntoJob(script: Self.downloadBlockScript).map(\.text) + + #expect(rows == [ + "Cask alpha Downloaded", + "Cask beta Downloaded", + "Cask gamma Downloaded", + ]) + } + + @Test func `a redrawn download block never splices two casks onto one row`() async throws { + let rows = try await runIntoJob(script: Self.downloadBlockScript).map(\.text) + + #expect(rows.allSatisfy { $0.components(separatedBy: "Cask").count <= 2 }) + } + + @Test func `the transcript records settled rows, not every frame`() async throws { + let transcript = try await runReturningTranscript(script: Self.downloadBlockScript) + + #expect(transcript == """ + Cask alpha Downloaded + Cask beta Downloaded + Cask gamma Downloaded + + """) + } } private extension TerminalProgressIntegrationTests { + /// The assembled `standardOutput`, as opposed to what the console is streamed. + func runReturningTranscript(script: String) async throws -> String { + let service = BrewCommandService() + let output = try await service.run( + executableURL: URL(fileURLWithPath: "/bin/zsh"), + arguments: ["-c", script], + options: BrewRunOptions(output: .pseudoTerminal), + ) + return output.standardOutput + } + /// Replays the streamed lines into a ``CommandJob`` as the console does, returning the rows a user /// would see. func runIntoJob(script: String) async throws -> [BrewCommandOutputLine] { diff --git a/Tests/BrewCLITests/TerminalTranscriptTests.swift b/Tests/BrewCLITests/TerminalTranscriptTests.swift new file mode 100644 index 00000000..5d87ebe2 --- /dev/null +++ b/Tests/BrewCLITests/TerminalTranscriptTests.swift @@ -0,0 +1,137 @@ +// +// TerminalTranscriptTests.swift +// BrewTests +// + +@testable import BrewCLI +import BrewCore +import Testing + +struct TerminalTranscriptTests { + @Test func `an untouched transcript is empty`() { + #expect(TerminalTranscript().text.isEmpty) + } + + @Test func `committed rows are newline separated and newline terminated`() { + var transcript = TerminalTranscript() + + transcript.apply(.committed(line("one"))) + transcript.apply(.committed(line("two"))) + + #expect(transcript.text == "one\ntwo\n") + } + + // MARK: - Trailing newlines + + @Test func `a settled row does not gain a trailing newline`() { + // Output that stopped without a newline must not grow one: `printf 'tty'` is exactly `tty`. + var transcript = TerminalTranscript() + + transcript.settle("tty") + + #expect(transcript.text == "tty") + } + + @Test func `a settled row after committed ones ends the transcript without a newline`() { + var transcript = TerminalTranscript() + transcript.apply(.committed(line("one"))) + + transcript.settle("partial") + + #expect(transcript.text == "one\npartial") + } + + @Test func `settling replaces the row it was still drawing`() { + var transcript = TerminalTranscript() + transcript.apply(.revised(line("50%"), rowOffset: 0)) + + transcript.settle("100%") + + #expect(transcript.text == "100%") + } + + // MARK: - Offset zero + + @Test func `revisions at offset zero replace rather than accumulate`() { + var transcript = TerminalTranscript() + + for percent in stride(from: 0, through: 100, by: 25) { + transcript.apply(.revised(line("#### \(percent)%"), rowOffset: 0)) + } + + #expect(transcript.text == "#### 100%") + } + + @Test func `committing settles the row being revised instead of appending`() { + var transcript = TerminalTranscript() + transcript.apply(.revised(line("50%"), rowOffset: 0)) + + transcript.apply(.committed(line("100%"))) + transcript.apply(.committed(line("Downloaded"))) + + #expect(transcript.text == "100%\nDownloaded\n") + } + + // MARK: - Positive offsets + + @Test func `a positive offset rewrites that many rows back`() { + var transcript = TerminalTranscript() + for text in ["alpha", "beta", "gamma"] { + transcript.apply(.committed(line(text))) + } + + transcript.apply(.revised(line("ALPHA"), rowOffset: 2)) + + #expect(transcript.text == "ALPHA\nbeta\ngamma\n") + } + + @Test func `a positive offset leaves the target's newline alone`() { + // The row already ended; only its content can change. + var transcript = TerminalTranscript() + transcript.apply(.committed(line("alpha"))) + transcript.settle("gamma") + + transcript.apply(.revised(line("ALPHA"), rowOffset: 1)) + + #expect(transcript.text == "ALPHA\ngamma") + } + + @Test func `an offset past the start is dropped`() { + var transcript = TerminalTranscript() + transcript.apply(.committed(line("only"))) + + transcript.apply(.revised(line("nowhere"), rowOffset: 9)) + + #expect(transcript.text == "only\n") + } + + @Test func `an offset into an empty transcript is dropped`() { + var transcript = TerminalTranscript() + + transcript.apply(.revised(line("nowhere"), rowOffset: 3)) + + #expect(transcript.text.isEmpty) + } + + // MARK: - Redrawn blocks + + @Test func `a redrawn block records its settled rows, not every frame`() { + var transcript = TerminalTranscript() + transcript.apply(.committed(line("alpha 1MB"))) + transcript.apply(.committed(line("beta 1MB"))) + transcript.apply(.revised(line("gamma 1MB"), rowOffset: 0)) + + for tick in 2 ... 20 { + transcript.apply(.revised(line("alpha \(tick)MB"), rowOffset: 2)) + transcript.apply(.revised(line("beta \(tick)MB"), rowOffset: 1)) + transcript.apply(.revised(line("gamma \(tick)MB"), rowOffset: 0)) + } + transcript.settle("gamma 20MB") + + #expect(transcript.text == "alpha 20MB\nbeta 20MB\ngamma 20MB") + } +} + +private func line(_ text: String) -> TerminalLine { + TerminalLine(spans: text.isEmpty ? [] : [ANSISpan(text: text, style: .default)]) +} diff --git a/Tests/BrewCoreTests/TerminalLineAssemblerTests.swift b/Tests/BrewCoreTests/TerminalLineAssemblerTests.swift index 2fb38195..b01e10ec 100644 --- a/Tests/BrewCoreTests/TerminalLineAssemblerTests.swift +++ b/Tests/BrewCoreTests/TerminalLineAssemblerTests.swift @@ -30,7 +30,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("partial") - #expect(events == [.revised(line("partial"))]) + #expect(events == [.revised(line("partial"), rowOffset: 0)]) } // MARK: - Carriage returns @@ -40,7 +40,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("first\rX") - #expect(events.last == .revised(line("Xirst"))) + #expect(events.last == .revised(line("Xirst"), rowOffset: 0)) } @Test func `a full-width redraw replaces the previous one entirely`() { @@ -49,7 +49,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("## 6.3%\r###### 50.0%") - #expect(events.last == .revised(line("###### 50.0%"))) + #expect(events.last == .revised(line("###### 50.0%"), rowOffset: 0)) } @Test func `a whole progress bar collapses to one committed line`() { @@ -70,7 +70,7 @@ struct TerminalLineAssemblerTests { var assembler = TerminalLineAssembler() let events = assembler.consume("longer text\rshort") - #expect(events.last == .revised(line("shortr text"))) + #expect(events.last == .revised(line("shortr text"), rowOffset: 0)) } @Test func `a carriage return before a newline still commits the overwritten line`() { @@ -88,7 +88,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("longer text\rshort\u{1B}[K") - #expect(events.last == .revised(line("short"))) + #expect(events.last == .revised(line("short"), rowOffset: 0)) } @Test func `erase whole line empties it`() { @@ -96,7 +96,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("content\u{1B}[2K") - #expect(events.last == .revised(line(""))) + #expect(events.last == .revised(line(""), rowOffset: 0)) } @Test func `backspace steps the cursor back one column`() { @@ -104,7 +104,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("abc\u{08}X") - #expect(events.last == .revised(line("abX"))) + #expect(events.last == .revised(line("abX"), rowOffset: 0)) } // MARK: - Styling @@ -173,7 +173,7 @@ struct TerminalLineAssemblerTests { let events = assembler.consume("1%\r2%\r3%\r4%\r5%") - #expect(events == [.revised(line("5%"))]) + #expect(events == [.revised(line("5%"), rowOffset: 0)]) } // MARK: - Flushing @@ -299,6 +299,176 @@ struct TerminalLineAssemblerTests { // Blanks columns 0-2; the cursor stays at column 2, where X then lands. #expect(events.compactMap(\.committedText) == [" Xde"]) } + + // MARK: - Multi-row progress blocks + + /// One frame of Homebrew's download block: N rows, the last newline-less, then `ESC[F`. + private static func downloadFrame(_ rows: [String]) -> String { + let body = rows.enumerated() + .map { index, row in "\(row)\u{1B}[K\(index == rows.count - 1 ? "" : "\n")" } + .joined() + return "\u{1B}[?2026h" + body + "\u{1B}[\(rows.count - 1)F" + "\u{1B}[?2026l" + } + + private static func downloadFrame(tick: Int) -> String { + downloadFrame([ + "Cask alpha # Downloading \(tick)MB/10MB", + "Cask beta # Downloading \(tick)MB/20MB", + "Cask gamma # Downloading \(tick)MB/30MB", + ]) + } + + @Test func `a redrawn download block revises its rows instead of repeating them`() { + var assembler = TerminalLineAssembler() + var committed = 0 + + for tick in 1 ... 5 { + committed += assembler.consume(Self.downloadFrame(tick: tick)).compactMap(\.committedText).count + } + + // Two rows commit; the third stays pending. + #expect(committed == 2) + } + + @Test func `a redrawn download block does not splice two rows together`() { + var assembler = TerminalLineAssembler() + var texts: [String] = [] + + for tick in 1 ... 5 { + for event in assembler.consume(Self.downloadFrame(tick: tick)) { + switch event { + case let .committed(line): texts.append(line.text) + case let .revised(line, _): texts.append(line.text) + } + } + } + + #expect(!texts.contains { $0.components(separatedBy: "Cask").count > 2 }) + } + + @Test func `a redrawn download block reports each row at its own offset`() { + var assembler = TerminalLineAssembler() + _ = assembler.consume(Self.downloadFrame(tick: 1)) + + let revisions = assembler.consume(Self.downloadFrame(tick: 2)).compactMap(\.revision) + + // Top row of the block is two rows above the one still being written. + #expect(revisions.contains { $0.offset == 2 && $0.text.contains("alpha") && $0.text.contains("2MB") }) + #expect(revisions.contains { $0.offset == 1 && $0.text.contains("beta") && $0.text.contains("2MB") }) + #expect(revisions.contains { $0.offset == 0 && $0.text.contains("gamma") && $0.text.contains("2MB") }) + } + + @Test func `each row is revised at most once per chunk`() { + var assembler = TerminalLineAssembler() + _ = assembler.consume(Self.downloadFrame(tick: 1)) + + // Two whole frames in one read. + let events = assembler.consume(Self.downloadFrame(tick: 2) + Self.downloadFrame(tick: 3)) + let offsets = events.compactMap(\.revision).map(\.offset) + + #expect(offsets.count == Set(offsets).count) + } + + @Test func `the final frame is what settles`() { + var assembler = TerminalLineAssembler() + for tick in 1 ... 4 { + _ = assembler.consume(Self.downloadFrame(tick: tick)) + } + + let settled = assembler.consume(Self.downloadFrame(tick: 9)).compactMap(\.revision) + + #expect(settled.allSatisfy { $0.text.contains("9MB") }) + } + + // MARK: - Vertical cursor movement + + @Test func `cursor previous line returns to column zero`() { + var assembler = TerminalLineAssembler() + + // Column 0, so X lands over the "a". + let events = assembler.consume("abc\ndef\u{1B}[1FX") + + #expect(events.compactMap(\.revision).contains { $0.offset == 1 && $0.text == "Xbc" }) + } + + @Test func `cursor up keeps the column`() { + var assembler = TerminalLineAssembler() + + // Stays at column 3, past the end of "ab", so it pads. + let events = assembler.consume("ab\nxyz\u{1B}[1AX") + + #expect(events.compactMap(\.revision).contains { $0.offset == 1 && $0.text == "ab X" }) + } + + @Test func `cursor down returns toward the newest row`() { + var assembler = TerminalLineAssembler() + + let events = assembler.consume("one\ntwo\nthree\u{1B}[2F\u{1B}[1BX") + + // Up two rows then back down one lands on "two". + #expect(events.compactMap(\.revision).contains { $0.offset == 1 && $0.text == "Xwo" }) + } + + @Test func `a newline inside a block steps down rather than opening a row`() { + var assembler = TerminalLineAssembler() + _ = assembler.consume("one\ntwo\nthree") + + let events = assembler.consume("\u{1B}[2FA\nB\nC") + + #expect(events.compactMap(\.committedText).isEmpty) + #expect(events.compactMap(\.revision).map(\.text).sorted() == ["Ane", "Bwo", "Chree"]) + } + + @Test func `moving above the window is ignored rather than writing to the wrong row`() { + // The move is dropped, so X lands on the pending row rather than an unrelated one. + var assembler = TerminalLineAssembler(windowDepth: 2) + _ = assembler.consume("one\ntwo\nthree\nfour") + + let events = assembler.consume("\u{1B}[9FX") + + #expect(events.compactMap(\.revision).contains { $0.offset == 0 && $0.text == "Xour" }) + } + + @Test func `rows that scroll out of the window are settled`() { + var assembler = TerminalLineAssembler(windowDepth: 1) + _ = assembler.consume("keep\ndrop\nlast") + + // Only one row of history is retained, so the row two back is out of reach. + let events = assembler.consume("\u{1B}[2FX") + + #expect(!events.compactMap(\.revision).contains { $0.offset == 2 }) + } + + @Test func `erasing applies to the row the cursor is on`() { + var assembler = TerminalLineAssembler() + + let events = assembler.consume("abcdef\nghi\u{1B}[1F\u{1B}[3C\u{1B}[K") + + #expect(events.compactMap(\.revision).contains { $0.offset == 1 && $0.text == "abc" }) + } + + @Test func `a single-line progress bar is unaffected by the window`() { + var assembler = TerminalLineAssembler() + + var events: [TerminalLineEvent] = [] + for percent in stride(from: 0, through: 100, by: 20) { + events += assembler.consume("#\(percent)%\r") + } + events += assembler.consume("\n") + + #expect(events.compactMap(\.committedText) == ["#100%"]) + #expect(events.compactMap(\.revision).allSatisfy { $0.offset == 0 }) + } + + @Test func `flushing settles the row still being drawn, not the one the cursor sits on`() { + var assembler = TerminalLineAssembler() + + // The cursor is parked two rows up, so X revises "one" and "three" stays pending. + let events = assembler.consume("one\ntwo\nthree\u{1B}[2FX") + + #expect(events.compactMap(\.revision).contains { $0.offset == 2 && $0.text == "Xne" }) + #expect(assembler.flush()?.text == "three") + } } private func line(_ text: String) -> TerminalLine { @@ -316,7 +486,11 @@ private extension TerminalLineEvent { } var revisedText: String? { - if case let .revised(line) = self { return line.text } + revision?.text + } + + var revision: (text: String, offset: Int)? { + if case let .revised(line, rowOffset) = self { return (line.text, rowOffset) } return nil } } diff --git a/Tests/BrewFeatureConsoleTests/CommandJobRevisionTests.swift b/Tests/BrewFeatureConsoleTests/CommandJobRevisionTests.swift index 5c5b99cb..9d30d033 100644 --- a/Tests/BrewFeatureConsoleTests/CommandJobRevisionTests.swift +++ b/Tests/BrewFeatureConsoleTests/CommandJobRevisionTests.swift @@ -69,6 +69,76 @@ struct CommandJobRevisionTests { #expect(job.output.map(\.text) == ["line 3", "line 4", "line 5"]) } + + // MARK: - Multi-row revisions + + @Test func `a row offset revises that many rows back from the end`() { + let job = makeJob() + for text in ["alpha", "beta", "gamma"] { + job.appendOutput(line(text, isComplete: true)) + } + + job.appendOutput(line("ALPHA", isComplete: true, rowOffset: 2)) + + #expect(job.output.map(\.text) == ["ALPHA", "beta", "gamma"]) + } + + @Test func `a revised row keeps its identity so the list animates it`() { + let job = makeJob() + for text in ["alpha", "beta", "gamma"] { + job.appendOutput(line(text, isComplete: true)) + } + let originalID = job.output[0].id + + job.appendOutput(line("ALPHA", isComplete: true, rowOffset: 2)) + + #expect(job.output[0].id == originalID) + } + + @Test func `a whole redrawn block leaves one row per entry`() { + let job = makeJob() + for text in ["alpha 1MB", "beta 1MB"] { + job.appendOutput(line(text, isComplete: true)) + } + job.appendOutput(line("gamma 1MB", isComplete: false)) + + for tick in 2 ... 20 { + job.appendOutput(line("alpha \(tick)MB", isComplete: true, rowOffset: 2)) + job.appendOutput(line("beta \(tick)MB", isComplete: true, rowOffset: 1)) + job.appendOutput(line("gamma \(tick)MB", isComplete: false, rowOffset: 0)) + } + + #expect(job.output.map(\.text) == ["alpha 20MB", "beta 20MB", "gamma 20MB"]) + } + + @Test func `an offset past the start of the buffer is dropped`() { + let job = makeJob() + job.appendOutput(line("only", isComplete: true)) + + job.appendOutput(line("nowhere", isComplete: true, rowOffset: 5)) + + #expect(job.output.map(\.text) == ["only"]) + } + + @Test func `offsets stay correct after the line cap trims the front`() { + let job = makeJob(maxOutputLines: 3) + for index in 1 ... 5 { + job.appendOutput(line("line \(index)", isComplete: true)) + } + + job.appendOutput(line("REVISED", isComplete: true, rowOffset: 1)) + + #expect(job.output.map(\.text) == ["line 3", "REVISED", "line 5"]) + } + + @Test func `a revision at offset zero still replaces the trailing row`() { + let job = makeJob() + job.appendOutput(line("10%", isComplete: false)) + + job.appendOutput(line("90%", isComplete: false, rowOffset: 0)) + + #expect(job.output.map(\.text) == ["90%"]) + } } @MainActor @@ -82,6 +152,6 @@ private func makeJob(maxOutputLines: Int = 50000) -> CommandJob { ) } -private func line(_ text: String, isComplete: Bool) -> BrewCommandOutputLine { - BrewCommandOutputLine(stream: .stdout, text: text, isComplete: isComplete) +private func line(_ text: String, isComplete: Bool, rowOffset: Int = 0) -> BrewCommandOutputLine { + BrewCommandOutputLine(stream: .stdout, text: text, isComplete: isComplete, rowOffset: rowOffset) }