diff --git a/lib/cli/ui/ansi.rb b/lib/cli/ui/ansi.rb index fbdcda13..5e15e27c 100644 --- a/lib/cli/ui/ansi.rb +++ b/lib/cli/ui/ansi.rb @@ -4,6 +4,9 @@ module CLI module UI module ANSI + autoload :Replay, 'cli/ui/ansi/replay' + private_constant :Replay + ESC = "\x1b" # https://ghostty.org/docs/vt/concepts/sequences#csi-sequences CSI_SEQUENCE = /\x1b\[[\d;:]+[\x20-\x2f]*?[\x40-\x7e]/ @@ -48,6 +51,21 @@ def strip_codes(str) str.gsub(Regexp.union(CSI_SEQUENCE, OSC_SEQUENCE, /\r/), '') end + # Returns the text left by applying terminal repaint controls in a + # captured stream. Presentation controls are dropped and malformed + # input is replaced while decoding the result as UTF-8. + # + # ==== Attributes + # + # - +str+ - The captured terminal stream to replay + # - +diagnostics+ - Optional hash populated when replay encounters + # terminal operations it cannot model + # + #: (String str, ?diagnostics: Hash[Symbol, bool]?) -> String + def replay(str, diagnostics: nil) + Replay.render(str, diagnostics: diagnostics) + end + # Returns an ANSI control sequence # # ==== Attributes diff --git a/lib/cli/ui/ansi/replay.rb b/lib/cli/ui/ansi/replay.rb new file mode 100644 index 00000000..5b8b4981 --- /dev/null +++ b/lib/cli/ui/ansi/replay.rb @@ -0,0 +1,782 @@ +# typed: true +# frozen_string_literal: true + +require 'strscan' +require_relative 'terminal_width' + +module CLI + module UI + module ANSI + # Replays a captured terminal stream, collapsing repaints into the text + # they settled on. + # + # Anything that repaints by moving the cursor -- spinners, spin groups, + # progress bars, a prompt redrawing itself -- writes one frame per tick to + # the stream. A capture of that stream (a log file, +StdoutRouter+'s + # duplicate output, test output) therefore holds every frame. Stripping + # the control sequences leaves them all side by side; applying them + # collapses each repaint onto the last, which is what the screen showed. + # + # Operations that assume a viewport -- screen-relative positioning + # (CUP), display erasure (ED), wrapping -- are ignored: a capture does + # not record scrolling, so a screen coordinate has no buffer row to + # map onto. Alternate-screen modes (?47, ?1047, ?1049) need no + # viewport: what a full-screen UI draws there is discarded on exit, as + # a terminal discards it, never reaching scrollback. Commands outside + # the repaint vocabulary -- character editing (ICH, DCH, ECH), scroll + # regions, tab-stop setting, charset translation (SO/SI) -- are dropped + # without effect. Each column holds one grapheme cluster, sized by + # TerminalWidth.grapheme_width: a wide glyph owns two columns, and + # overwriting either half blanks the other, as a terminal does. + # Trailing whitespace on every line is trimmed from the result: a + # terminal renders nothing there. + module Replay + # A run of characters a terminal displays: everything but C0 controls + # and DEL. + PRINTABLE = /[^\x00-\x1f\x7f]+/ + # A mark at the start of a printable run may continue the grapheme + # before it: presentation sequences do not move the cursor or break + # the glyph a terminal is assembling. + LEADING_MARK = /\A\p{M}/ + # Every C0 control except ESC, plus DEL. A terminal displays none of + # them: a few move the cursor, and simple_control drops the rest. + SIMPLE_CONTROL = /[\x00-\x1a\x1c-\x1f\x7f]/ + # Parameter bytes, then intermediate bytes, then one final byte: + # nearly every CSI sequence in a capture, matched in one pass. The + # ones it can't match -- garbled, aborted, cut off, or carrying + # embedded controls -- take the csi state's character loop, and both + # paths funnel into the same apply, so neither can drift on dispatch. + CSI_BODY = /([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])/ + # An OSC payload runs to its end: BEL or ST terminate it, CAN and SUB + # abort it, and an aborting ESC starts a sequence of its own. + OSC_PAYLOAD = /[^\x07\x18\x1a\e]+/ + # DCS, SOS, PM, and APC payloads end the same ways, BEL aside. + CONTROL_STRING_PAYLOAD = /[^\x18\x1a\e]+/ + # A real terminal clamps cursor moves to its bounds and drops what + # scrolls off. A replay has no viewport, so it caps instead: garbled + # control codes can't inflate the screen, and real output is never held + # back by the cap. + MAX_DISTANCE = 1024 + MAX_COLUMN = 4096 + MAX_PADDING = 10_000 + # Terminals place tab stops every eight columns. + TAB_STOP = 8 + # The second column of a wide glyph holds this marker: the glyph + # owns both cells, but only the first contributes to the output. + CONTINUATION = '' + # DEC's three alternate-buffer modes differ in clearing and cursor + # save semantics. Replay only needs their shared property: content + # drawn there does not enter the main screen's scrollback. + ALTERNATE_SCREEN_MODES = ['47', '1047', '1049'].freeze + # These standard CSI commands depend on viewport coordinates, bounds, + # or scroll margins that a captured stream does not record. + VIEWPORT_FINALS = ['H', 'J', 'S', 'T', 'd', 'f', 'r'].freeze + VIEWPORT_SCROLL_FINALS = ['@', 'A'].freeze + + # A grid of lines with no viewport. Lines stay compact strings while + # output only appends to them; moving back to edit one promotes it to + # an array with one cell per terminal column. This keeps ordinary logs + # proportional to their input while preserving terminal semantics for + # repainted rows. Height is unbounded because a capture has no + # scrollback to lose, width because the writer has already truncated + # to the terminal. + class Screen + #: -> void + def initialize + @lines = [+''] #: Array[String | Array[String]] + # Compact strings need their terminal width because String#length + # is not a column count for wide graphemes. Promoted rows use the + # cell array's length and have a nil entry here. + @widths = [0] #: Array[Integer?] + # Only the current compact row needs its trailing grapheme cached. + # Returning to a nonempty row without a cache promotes it. + @trailing_cluster = nil #: String? + @row = 0 #: Integer + @col = 0 #: Integer + @padding = 0 #: Integer + @saved = [0, 0] #: Array[Integer] + @conjured = {}.compare_by_identity #: Hash[String | Array[String], bool] + @alternate = nil #: [Array[String | Array[String]], Array[Integer?], Integer, Integer, Array[Integer], Integer]? + end + + #: (String text) -> void + def write(text) + line = materialize(@row) + if line.is_a?(String) && @col == @widths.fetch(@row) + if @trailing_cluster || line.empty? + append(line, text) + return + end + end + + line = promote(@row) + if text.ascii_only? + # Every character in an ASCII printable run is one column + # wide, so the whole run lands in one splice. + put(line, text.chars) + else + # A cursor move can leave an implicit blank before a combining + # mark. Materialize it so the mark has the same blank cell to + # combine with that a terminal has. + if text.match?(LEADING_MARK) && @col > line.length + line.concat(Array.new(@col - line.length, ' ')) + end + + continuing_clusters(line, text).each do |cluster| + # With no cell before the cursor, a terminal has nothing to + # combine a leading mark with and displays no new cell for it. + next if cluster.match?(LEADING_MARK) + + if TerminalWidth.grapheme_width(cluster) == 2 + put(line, [cluster, CONTINUATION]) + else + put(line, [cluster]) + end + end + end + end + + # The cursor may sit below the last line written, as it can in a + # terminal; the gap only becomes real if something writes into it. + #: (Integer rows) -> void + def move_rows(rows) + change_row((@row + rows).clamp(0, @lines.length - 1 + room)) + end + + #: (Integer cols) -> void + def move_columns(cols) + column(@col + cols) + end + + # A column real output reached is never denied to a cursor move; + # the cap holds back only columns a control sequence conjures. + #: (Integer col) -> void + def column(col) + limit = MAX_COLUMN + limit = [line_width(@row), limit].max if @row < @lines.length + @col = col.clamp(0, limit) + end + + # The gap a cursor move left behind is charged here, not to the + # line feed: only the row the feed itself produces is free. + #: -> void + def line_feed + materialize(@row) + change_row(@row + 1) + while @lines.length <= @row + @lines << +'' + @widths << 0 + end + end + + # A capture holds the writer's bare \n, but the tty driver's ONLCR + # gave the terminal \r\n: a newline returns the column home too. + #: -> void + def newline + line_feed + @col = 0 + end + + #: -> void + def carriage_return + @col = 0 + end + + # A tab moves the cursor; only a later write makes the gap real. + #: -> void + def tab + column(@col + TAB_STOP - (@col % TAB_STOP)) + end + + # DECSC/DECRC and their CSI twins. Restoring without a prior save + # homes the cursor, as xterm does. + #: -> void + def save_cursor + @saved = [@row, @col] + end + + #: -> void + def restore_cursor + change_row(@saved.fetch(0)) + @col = @saved.fetch(1) + end + + #: (Integer mode) -> void + def erase_line(mode) + return if @row >= @lines.length + + # Modes a terminal doesn't define are ignored, not coerced to zero. + line = @lines.fetch(@row) + if line.is_a?(String) + width = @widths.fetch(@row).to_i + case mode + when 0 + return if @col >= width + + if @col.zero? + line.clear + @widths[@row] = 0 + invalidate_trailing_cluster + return + end + when 2 + line.clear + @widths[@row] = 0 + invalidate_trailing_cluster + return + end + end + + line = promote(@row) + case mode + when 0 + split_wide(line, @col) + line.slice!(@col..) + when 1 + erased = [@col + 1, line.length].min + split_wide(line, erased) + line.fill(' ', 0, erased) + when 2 then line.clear + end + end + + #: (Integer count) -> void + def insert_lines(count) + materialize(@row) + inserted = [count, room].min + @padding += inserted + @lines[@row, 0] = Array.new(inserted) { conjure } + @widths[@row, 0] = Array.new(inserted, 0) + invalidate_trailing_cluster + end + + # Deleting a conjured row hands its charge back: an insert and its + # paired delete net to nothing. + #: (Integer count) -> void + def delete_lines(count) + removed = @lines.slice!(@row, count) + @widths.slice!(@row, count) + removed&.each { |line| @padding -= 1 if @conjured.delete(line) } + if @lines.empty? + @lines << +'' + @widths << 0 + end + invalidate_trailing_cluster + end + + # The alternate screen holds a full-screen UI -- a prompt, a pager + # -- whose content a terminal discards on exit: it never enters + # scrollback. Entering saves the grid and cursor and swaps to a + # scratch, as xterm's 1049 does; a second enter changes nothing. + #: -> void + def enter_alternate + return if @alternate + + @alternate = [@lines, @widths, @row, @col, @saved, @padding] + @lines = [+''] + @widths = [0] + @row = 0 + @col = 0 + @saved = [0, 0] + invalidate_trailing_cluster + end + + # Leaving discards the scratch grid and restores the saved one, + # cursor included. Restoring the padding count refunds whatever + # the scratch spent: its rows are gone. An exit with no matching + # enter changes nothing. + #: -> void + def exit_alternate + stash = @alternate + return unless stash + + @lines.each { |line| @conjured.delete(line) } + @lines, @widths, @row, @col, @saved, @padding = stash + @alternate = nil + invalidate_trailing_cluster + end + + # Trailing whitespace is trimmed from every line: a terminal renders + # nothing there, and erasure and padding leave blanks behind that + # were never content. A capture cut off inside the alternate screen + # never saw the exit, but its content still never reached + # scrollback: the saved grid is what the replay keeps. + #: -> String + def to_s + lines = @lines + if (stash = @alternate) + lines = stash.fetch(0) #: as Array[String | Array[String]] + end + lines.map { |line| line.is_a?(String) ? line.rstrip : line.join.rstrip }.join("\n") + end + + private + + # Appending to a compact row avoids retaining one String object per + # terminal cell. Re-segment only the cached boundary grapheme because + # presentation sequences can split a combining sequence. + #: (String line, String text) -> void + def append(line, text) + if text.ascii_only? + mark_content(line) + line << text + @col += text.length + @widths[@row] = @col + @trailing_cluster = text[-1] + return + end + + clusters = text.grapheme_clusters + previous = @trailing_cluster + if previous + combined = "#{previous}#{text}".grapheme_clusters + if combined.first != previous + old_width = TerminalWidth.grapheme_width(previous) + replacement = combined.shift.to_s + combined.reject! { |cluster| cluster.match?(LEADING_MARK) } + line.delete_suffix!(previous) + line << replacement << combined.join + @col += TerminalWidth.grapheme_width(replacement) - old_width + @col += combined.sum { |cluster| TerminalWidth.grapheme_width(cluster) } + @widths[@row] = @col + @trailing_cluster = combined.last || replacement + return + end + end + + clusters.reject! { |cluster| cluster.match?(LEADING_MARK) } + return if clusters.empty? + + mark_content(line) + clusters.each do |cluster| + line << cluster + @col += TerminalWidth.grapheme_width(cluster) + end + @widths[@row] = @col + @trailing_cluster = clusters.last + end + + # A presentation sequence can split one grapheme into separate + # printable runs: "e\e[31m\u0301" is still one displayed cell. + # Re-segment the new run with the glyph immediately before the + # cursor. If they join, replace that glyph in place and return only + # the clusters that remain to be written. + #: (Array[String] line, String text) -> Array[String] + def continuing_clusters(line, text) + clusters = text.grapheme_clusters + return clusters if @col.zero? + + previous_col = @col - 1 + previous_col -= 1 if CONTINUATION.equal?(line[previous_col]) + return clusters if previous_col.negative? + + previous = line[previous_col] + return clusters unless previous + + old_width = CONTINUATION.equal?(line[previous_col + 1]) ? 2 : 1 + # The cursor can be moved into the second half of a wide glyph. + # Only a complete glyph ending immediately before it can continue. + return clusters unless previous_col + old_width == @col + + combined = "#{previous}#{text}".grapheme_clusters + return clusters if combined.first == previous + + replacement = combined.shift.to_s + replace_cluster(line, previous_col, old_width, replacement) + combined + end + + # Replacing a cluster never shifts the cells after it. A variation + # selector can widen the preceding glyph, in which case it consumes + # the next cell and advances the cursor just as the terminal does. + #: (Array[String] line, Integer col, Integer old_width, String cluster) -> void + def replace_cluster(line, col, old_width, cluster) + mark_content(line) + new_width = TerminalWidth.grapheme_width(cluster) + span = [old_width, new_width].max + split_wide(line, col + span) + + cells = new_width == 2 ? [cluster, CONTINUATION] : [cluster] + cells.concat(Array.new(span - cells.length, ' ')) + line[col, span] = cells + @col += new_width - old_width + end + + # Splices cells into the current line at the cursor, one column + # each. + #: (Array[String] line, Array[String] cells) -> void + def put(line, cells) + mark_content(line) + line.concat(Array.new(@col - line.length, ' ')) if @col > line.length + split_wide(line, @col) + split_wide(line, @col + cells.length) + line[@col, cells.length] = cells + @col += cells.length + end + + # A conjured row that receives display content is content after all: + # hand its charge back. A leading combining mark at column zero is + # ignored before reaching here, so it cannot spend the padding cap. + #: (String | Array[String] line) -> void + def mark_content(line) + @padding -= 1 if @conjured.delete(line) + end + + # A cell boundary at col must not split a wide glyph: when col + # lands on its CONTINUATION, both halves blank, as a terminal + # blanks a glyph it can no longer show whole. + #: (Array[String] line, Integer col) -> void + def split_wide(line, col) + return unless CONTINUATION.equal?(line[col]) + + line[col - 1] = ' ' + line[col] = ' ' + end + + # Rows a control sequence conjured, as opposed to rows real output + # produced. Only these are capped, and only while they stay blank: + # a long capture is content, not garbage, and a conjured row that + # text lands on stops counting. + #: -> Integer + def room + [MAX_PADDING - @padding, 0].max + end + + #: -> String + def conjure + line = +'' + @conjured[line] = true + line + end + + # Fills in rows a cursor move skipped. The budget can overshoot by + # one move's worth: move_rows clamps against the room left when it + # runs, which insert_lines may since have spent. + #: (Integer row) -> (String | Array[String]) + def materialize(row) + gap = row + 1 - @lines.length + if gap.positive? + @padding += gap + while @lines.length <= row + @lines << conjure + @widths << 0 + end + end + @lines.fetch(row) + end + + # Converts a compact row to terminal cells the first time an + # operation needs to edit it in place. + #: (Integer row) -> Array[String] + def promote(row) + line = @lines.fetch(row) + return line unless line.is_a?(String) + + cells = [] #: Array[String] + line.grapheme_clusters.each do |cluster| + cells << cluster + cells << CONTINUATION if TerminalWidth.grapheme_width(cluster) == 2 + end + @conjured[cells] = true if @conjured.delete(line) + @lines[row] = cells + @widths[row] = nil + invalidate_trailing_cluster if row == @row + cells + end + + #: (Integer row) -> Integer + def line_width(row) + line = @lines.fetch(row) + line.is_a?(String) ? @widths.fetch(row).to_i : line.length + end + + #: (Integer row) -> void + def change_row(row) + return if row == @row + + @row = row + invalidate_trailing_cluster + end + + #: -> void + def invalidate_trailing_cluster + @trailing_cluster = nil + end + end + + class << self + # Replays +stream+ and returns what a terminal would have displayed. + # Colors and other presentation sequences are dropped, as they are by + # +ANSI.strip_codes+. The stream may arrive in any encoding -- + # captures are often read in binary mode -- and is decoded as UTF-8, + # replacing bytes that don't decode. If +diagnostics+ is provided, + # +:viewport_operations_seen+ is set when the stream uses an + # operation replay deliberately cannot model without viewport state. + # + # ==== Attributes + # + # - +stream+ - The captured terminal stream to replay + # - +diagnostics+ - Optional hash populated with replay limitations + # + #: (String stream, ?diagnostics: Hash[Symbol, bool]?) -> String + def render(stream, diagnostics: nil) + screen = Screen.new + scanner = StringScanner.new(normalize(stream)) + state = :ground #: Symbol + + until scanner.eos? + state = + case state + when :ground then ground(screen, scanner) + when :escape then escape(screen, scanner) + when :csi then csi(screen, scanner, diagnostics) + when :osc then string_body(scanner, OSC_PAYLOAD) + else string_body(scanner, CONTROL_STRING_PAYLOAD) + end + end + + screen.to_s + end + + private + + # A capture arrives however it was read: binmode strings tagged + # BINARY, mistagged text, other encodings. Columns are characters, + # not bytes, so decode to UTF-8 and replace what doesn't decode. + # Binary and mistagged strings usually hold UTF-8 bytes already, + # so they are retagged rather than transcoded. + #: (String stream) -> String + def normalize(stream) + if stream.encoding == Encoding::UTF_8 + stream.valid_encoding? ? stream : stream.scrub + elsif stream.encoding == Encoding::ASCII_8BIT || !stream.valid_encoding? + utf8 = stream.dup.force_encoding(Encoding::UTF_8) + utf8.valid_encoding? ? utf8 : utf8.scrub + else + stream.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + end + + # The stream is parsed by the VT500-series state machine + # (https://vt100.net/emu/dec_ansi_parser), one method per state, + # each consuming what its state recognizes and returning the next. + # From any state, CAN and SUB abort the sequence and a fresh ESC + # starts one of its own; embedded C0 controls execute mid-sequence, + # as they do on a real terminal. Only sequence bytes step character + # by character: printable runs and string payloads move in bulk. + # ANSI::CSI_SEQUENCE is no help here: it matches well-formed + # sequences whole, while interpreting needs the parameters and + # final apart, and must resolve the malformed rest the way a + # terminal does. + + # Ground state: text and the cursor controls that carry it. + #: (Screen screen, StringScanner scanner) -> Symbol + def ground(screen, scanner) + until scanner.eos? + if (text = scanner.scan(PRINTABLE)) + screen.write(text) + elsif (control = scanner.scan(SIMPLE_CONTROL)) + simple_control(screen, control) + elsif scanner.skip(/\e\[/) + # The transition nearly every escape in a capture takes, + # folded into one step; the rest route through escape. + return :csi + else + scanner.skip(/\e/) + return :escape + end + end + :ground + end + + # Escape state: the character after ESC selects the sequence kind. + # Finals that select commands a replay has no use for -- charset + # designation and the rest of the nF and Fp families -- drop here. + #: (Screen screen, StringScanner scanner) -> Symbol + def escape(screen, scanner) + until scanner.eos? + case (char = scanner.getch.to_s) + when '[' then return :csi + when ']' then return :osc + when 'P', 'X', '^', '_' then return :control_string + when '7' + screen.save_cursor + return :ground + when '8' + screen.restore_cursor + return :ground + when 'D' # IND feeds a line, keeping the column + screen.line_feed + return :ground + when 'E' # NEL starts the next line + screen.newline + return :ground + when 'M' # RI reverse-feeds a line, keeping the column + screen.move_rows(-1) + return :ground + when "\e" then next + when "\x18", "\x1a" then return :ground + when /[\x20-\x2f]/ + return escape_intermediate(screen, scanner) + when SIMPLE_CONTROL then simple_control(screen, char) + else return :ground + end + end + :ground + end + + # ESC intermediate state: collect through the final byte while + # executing embedded C0 controls and ignoring DEL. Bulk-skipping this + # tail would end the sequence at an embedded control, exposing its + # final byte as printable text. + #: (Screen screen, StringScanner scanner) -> Symbol + def escape_intermediate(screen, scanner) + until scanner.eos? + case (char = scanner.getch.to_s) + when /[\x30-\x7e]/ then return :ground + when /[\x20-\x2f]/ then next + when "\e" then return :escape + when "\x18", "\x1a" then return :ground + when SIMPLE_CONTROL then simple_control(screen, char) + else return :ground + end + end + :ground + end + + # CSI state: parameter bytes, then intermediate bytes, then one + # final byte. A garbled byte poisons the sequence -- it is consumed + # through its final but not executed, the VT500's ignore state. + #: (Screen screen, StringScanner scanner, Hash[Symbol, bool]? diagnostics) -> Symbol + def csi(screen, scanner, diagnostics) + if scanner.scan(CSI_BODY) + apply(screen, scanner[1].to_s, scanner[2].to_s, scanner[3].to_s, diagnostics) + return :ground + end + + params = +'' + intermediates = +'' + garbled = false #: bool + until scanner.eos? + case (char = scanner.getch.to_s) + when /[\x40-\x7e]/ + apply(screen, params, intermediates, char, diagnostics) unless garbled + return :ground + when /[\x30-\x3f]/ then params << char + when /[\x20-\x2f]/ then intermediates << char + when "\e" then return :escape + when "\x18", "\x1a" then return :ground + when SIMPLE_CONTROL then simple_control(screen, char) + else garbled = true + end + end + :ground + end + + # OSC and control-string states: a payload the terminal consumes + # without displaying. Whatever ends it -- terminator, abort, or the + # capture cutting off -- none of it reaches the screen. ST arrives + # as an ESC and resolves through the escape state. + #: (StringScanner scanner, Regexp payload) -> Symbol + def string_body(scanner, payload) + scanner.skip(payload) + scanner.getch == "\e" ? :escape : :ground + end + + # VT and FF feed a line but keep the column, as in xterm: the tty + # driver's ONLCR translates only \n. Everything else -- BEL, NUL, + # DEL, a standalone CAN -- falls through: a terminal displays + # nothing for it. + #: (Screen screen, String control) -> void + def simple_control(screen, control) + case control + when "\n" then screen.newline + when "\v", "\f" then screen.line_feed + when "\r" then screen.carriage_return + when "\t" then screen.tab + when "\x08" then screen.move_columns(-1) + end + end + + #: (Screen screen, String params, String intermediates, String final, Hash[Symbol, bool]? diagnostics) -> void + def apply(screen, params, intermediates, final, diagnostics) + if diagnostics && viewport_operation?(params, intermediates, final) + diagnostics[:viewport_operations_seen] = true + end + + # An intermediate byte selects a different command than the final + # byte alone: \e[1 A is scroll-right, not cursor-up. + return unless intermediates.empty? + + # DEC private modes (marked ?, like ?25l) change nothing a replay + # tracks, except the alternate screen: a full-screen UI draws + # there and a terminal discards it on exit, so it must not reach + # the replayed scrollback either. + if params.match?(/\A\?[\d;]*\z/) + modes = params.delete_prefix('?').split(';') + return unless modes.any? { |mode| ALTERNATE_SCREEN_MODES.include?(mode) } + + case final + when 'h' then screen.enter_alternate + when 'l' then screen.exit_alternate + end + return + end + + # The remaining private markers (<, =, >, ?) select commands + # outside the standard grammar: dispatch none of them. + return unless params.match?(/\A[\d;]*\z/) + + # Sequences that assume a viewport -- absolute positioning (H, f), + # erase-display (J) -- fall through: a replay has no bounds to + # position against, and ignoring them keeps captured content. + case final + when 'A' then screen.move_rows(-distance(params)) + when 'B', 'e' then screen.move_rows(distance(params)) + when 'C', 'a' then screen.move_columns(distance(params)) + when 'D' then screen.move_columns(-distance(params)) + when 'E', 'F' + screen.move_rows(final == 'E' ? distance(params) : -distance(params)) + screen.column(0) + when 'G', '`' then screen.column(argument(params, 1) - 1) + when 'K' then screen.erase_line(argument(params, 0)) + when 'L' then screen.insert_lines(distance(params)) + when 'M' then screen.delete_lines(distance(params)) + # A parameterized s is DECSLRM, setting scroll margins -- a + # viewport operation -- not a save. + when 's' then screen.save_cursor if params.empty? + when 'u' then screen.restore_cursor + end + end + + # CUP/HVP/VPA, ED, scrolling, and scroll-margin commands need a + # viewport origin and bounds. CSI SP @ and CSI SP A are the + # horizontal scroll commands; their intermediate byte distinguishes + # them from insert-character and cursor-up. + #: (String params, String intermediates, String final) -> bool + def viewport_operation?(params, intermediates, final) + return false unless params.match?(/\A[\d;]*\z/) + + if intermediates.empty? + VIEWPORT_FINALS.include?(final) || (final == 's' && !params.empty?) + else + intermediates == ' ' && VIEWPORT_SCROLL_FINALS.include?(final) + end + end + + # Movement counts default to 1, and terminals read an explicit 0 as 1 too. + #: (String params) -> Integer + def distance(params) + argument(params, 1).clamp(1, MAX_DISTANCE) + end + + #: (String params, Integer default) -> Integer + def argument(params, default) + value = params.split(';').first + value.nil? || value.empty? ? default : value.to_i + end + end + end + end + end +end diff --git a/lib/cli/ui/ansi/terminal_width.rb b/lib/cli/ui/ansi/terminal_width.rb new file mode 100644 index 00000000..4c45f63f --- /dev/null +++ b/lib/cli/ui/ansi/terminal_width.rb @@ -0,0 +1,44 @@ +# typed: true +# frozen_string_literal: true + +require_relative 'width_data' + +module CLI + module UI + module ANSI + # Internal terminal-column measurements used by ANSI replay. Existing + # layout APIs keep their current width behavior until they deliberately + # adopt this implementation. + module TerminalWidth + VS16 = 0xFE0F + + class << self + # The number of terminal columns occupied by one grapheme cluster. + # + #: (String cluster) -> Integer + def grapheme_width(cluster) + case cluster + when "\n", "\r", "\r\n" + 0 + else + codepoint = cluster.ord + wide = WIDE_RANGES.bsearch do |range| + if codepoint < range.begin + -1 + elsif codepoint > range.end + 1 + else + 0 + end + end + return 2 if wide + + cluster.length > 1 && cluster.each_codepoint.include?(VS16) ? 2 : 1 + end + end + end + end + private_constant :TerminalWidth + end + end +end diff --git a/lib/cli/ui/ansi/width_data.rb b/lib/cli/ui/ansi/width_data.rb new file mode 100644 index 00000000..43cb5ec0 --- /dev/null +++ b/lib/cli/ui/ansi/width_data.rb @@ -0,0 +1,156 @@ +# typed: true +# frozen_string_literal: true + +# Generated by `bundle exec rake unicode:generate_width_data`. +# Unicode version: 17.0.0 +# Unicode data © 2025 Unicode, Inc. +# Terms: https://www.unicode.org/terms_of_use.html +# Sources: +# - https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt +# SHA-256: ea7ce50f3444a050333448dffef1cadd9325af55cbb764b4a2280faf52170a33 (EastAsianWidth.txt) +# - https://www.unicode.org/Public/17.0.0/ucd/emoji/emoji-data.txt +# SHA-256: 2cb2bb9455cda83e8481541ecf5b6dfda66a3bb89efa3fa7c5297eccf607b72b (emoji-data.txt) +# Width ranges SHA-256: 438fca17ab102feab3ff7765b1a44a15aea2932af364a4493f3c76989f621131 +# +# Do not edit this file by hand. Update the pinned version and checksums +# in rakelib/unicode.rake, then regenerate it. + +module CLI + module UI + module ANSI + module TerminalWidth + UNICODE_VERSION = '17.0.0' + + # Codepoints with East_Asian_Width=Wide or Fullwidth, plus those + # with Emoji_Presentation=Yes. VS16 presentation is handled by + # TerminalWidth.grapheme_width because it is a property of the + # cluster. + WIDE_RANGES = [ + 0x1100..0x115F, + 0x231A..0x231B, + 0x2329..0x232A, + 0x23E9..0x23EC, + 0x23F0..0x23F0, + 0x23F3..0x23F3, + 0x25FD..0x25FE, + 0x2614..0x2615, + 0x2630..0x2637, + 0x2648..0x2653, + 0x267F..0x267F, + 0x268A..0x268F, + 0x2693..0x2693, + 0x26A1..0x26A1, + 0x26AA..0x26AB, + 0x26BD..0x26BE, + 0x26C4..0x26C5, + 0x26CE..0x26CE, + 0x26D4..0x26D4, + 0x26EA..0x26EA, + 0x26F2..0x26F3, + 0x26F5..0x26F5, + 0x26FA..0x26FA, + 0x26FD..0x26FD, + 0x2705..0x2705, + 0x270A..0x270B, + 0x2728..0x2728, + 0x274C..0x274C, + 0x274E..0x274E, + 0x2753..0x2755, + 0x2757..0x2757, + 0x2795..0x2797, + 0x27B0..0x27B0, + 0x27BF..0x27BF, + 0x2B1B..0x2B1C, + 0x2B50..0x2B50, + 0x2B55..0x2B55, + 0x2E80..0x2E99, + 0x2E9B..0x2EF3, + 0x2F00..0x2FD5, + 0x2FF0..0x303E, + 0x3041..0x3096, + 0x3099..0x30FF, + 0x3105..0x312F, + 0x3131..0x318E, + 0x3190..0x31E5, + 0x31EF..0x321E, + 0x3220..0x3247, + 0x3250..0xA48C, + 0xA490..0xA4C6, + 0xA960..0xA97C, + 0xAC00..0xD7A3, + 0xF900..0xFAFF, + 0xFE10..0xFE19, + 0xFE30..0xFE52, + 0xFE54..0xFE66, + 0xFE68..0xFE6B, + 0xFF01..0xFF60, + 0xFFE0..0xFFE6, + 0x16FE0..0x16FE4, + 0x16FF0..0x16FF6, + 0x17000..0x18CD5, + 0x18CFF..0x18D1E, + 0x18D80..0x18DF2, + 0x1AFF0..0x1AFF3, + 0x1AFF5..0x1AFFB, + 0x1AFFD..0x1AFFE, + 0x1B000..0x1B122, + 0x1B132..0x1B132, + 0x1B150..0x1B152, + 0x1B155..0x1B155, + 0x1B164..0x1B167, + 0x1B170..0x1B2FB, + 0x1D300..0x1D356, + 0x1D360..0x1D376, + 0x1F004..0x1F004, + 0x1F0CF..0x1F0CF, + 0x1F18E..0x1F18E, + 0x1F191..0x1F19A, + 0x1F1E6..0x1F202, + 0x1F210..0x1F23B, + 0x1F240..0x1F248, + 0x1F250..0x1F251, + 0x1F260..0x1F265, + 0x1F300..0x1F320, + 0x1F32D..0x1F335, + 0x1F337..0x1F37C, + 0x1F37E..0x1F393, + 0x1F3A0..0x1F3CA, + 0x1F3CF..0x1F3D3, + 0x1F3E0..0x1F3F0, + 0x1F3F4..0x1F3F4, + 0x1F3F8..0x1F43E, + 0x1F440..0x1F440, + 0x1F442..0x1F4FC, + 0x1F4FF..0x1F53D, + 0x1F54B..0x1F54E, + 0x1F550..0x1F567, + 0x1F57A..0x1F57A, + 0x1F595..0x1F596, + 0x1F5A4..0x1F5A4, + 0x1F5FB..0x1F64F, + 0x1F680..0x1F6C5, + 0x1F6CC..0x1F6CC, + 0x1F6D0..0x1F6D2, + 0x1F6D5..0x1F6D8, + 0x1F6DC..0x1F6DF, + 0x1F6EB..0x1F6EC, + 0x1F6F4..0x1F6FC, + 0x1F7E0..0x1F7EB, + 0x1F7F0..0x1F7F0, + 0x1F90C..0x1F93A, + 0x1F93C..0x1F945, + 0x1F947..0x1F9FF, + 0x1FA70..0x1FA7C, + 0x1FA80..0x1FA8A, + 0x1FA8E..0x1FAC6, + 0x1FAC8..0x1FAC8, + 0x1FACD..0x1FADC, + 0x1FADF..0x1FAEA, + 0x1FAEF..0x1FAF8, + 0x20000..0x2FFFD, + 0x30000..0x3FFFD, + ].freeze + end + end + end +end diff --git a/rakelib/replay_fixtures.rake b/rakelib/replay_fixtures.rake new file mode 100644 index 00000000..ea93fa42 --- /dev/null +++ b/rakelib/replay_fixtures.rake @@ -0,0 +1,169 @@ +# typed: true +# frozen_string_literal: true + +require 'open3' +require 'stringio' + +# Regenerates the ANSI replay fixtures: `capture` re-renders the .raw captures +# from cli-ui itself, `bless` re-derives the .expected files from xterm.js. +# See test/fixtures/replay/README.md. +module ReplayFixtures + DIR = File.expand_path('../test/fixtures/replay', __dir__) + ORACLE = File.join(DIR, 'oracle.js') + INSTALL = 'npm ci' + + # Holds every task until the group has repainted, so a capture records + # spinner frames instead of a single paint. + class Sink < StringIO + #: Queue + attr_reader :queue + + #: (Integer repaints, Integer tasks) -> void + def initialize(repaints, tasks) + super() + @repaints = repaints + @tasks = tasks + @queue = Queue.new #: Queue + @released = false #: bool + end + + #: (*untyped args) -> untyped + def print(*args) + super.tap do + next if @released || string.scan(/\e\[\d*A/).size < @repaints + + @released = true + @tasks.times { @queue << true } + end + end + end + + extend self + + #: -> void + def capture + require 'cli/ui' + CLI::UI.enable_cursor = true + CLI::UI.enable_color = true + CLI::UI::StdoutRouter.ensure_activated + + spin_group('spin_group', ['install dependencies', 'compile assets', 'run migrations'], repaints: 8) + spin_group('spin_group_wide_glyphs', ['日本語のタスク', '🔧 rebuild native extensions'], repaints: 4) + retitling_spin_group('spin_group_retitled') + progress_bar('progress_bar') + frames('frame_nested') + alternate_screen('alternate_screen_prompt') + end + + #: -> void + def bless + raise "#{ORACLE} needs node and its locked packages:\n cd #{DIR} && #{INSTALL}" unless node? + + Dir.glob(File.join(DIR, '*.raw')).sort.each do |raw| + expected, status = Open3.capture2('node', ORACLE, raw) + raise "oracle failed on #{File.basename(raw)}" unless status.success? + + write("#{File.basename(raw, ".raw")}.expected", expected) + end + end + + private + + #: -> bool + def node? + Open3.capture2e('node', '--version').last.success? + rescue Errno::ENOENT + false + end + + #: (String name, String contents) -> void + def write(name, contents) + File.binwrite(File.join(DIR, name), contents) + puts format('%-30s %6d B', name, contents.bytesize) + end + + #: (String name, Array[String] titles, repaints: Integer) -> void + def spin_group(name, titles, repaints:) + to = Sink.new(repaints, titles.length) + group = CLI::UI::SpinGroup.new(auto_debrief: false) + titles.each { |title| group.add(title) { to.queue.pop(timeout: 5) } } + group.wait(to: to) + write("#{name}.raw", to.string) + end + + #: (String name) -> void + def retitling_spin_group(name) + to = Sink.new(3, 1) + group = CLI::UI::SpinGroup.new(auto_debrief: false) + group.add('bundle install') do |task| + 3.times do |i| + task.update_title("bundle install (gem #{i + 1})") + sleep(0.12) + end + to.queue.pop(timeout: 5) + end + group.wait(to: to) + write("#{name}.raw", to.string) + end + + #: (String name) -> void + def progress_bar(name) + write("#{name}.raw", to_stdout do + CLI::UI::Progress.progress('Building') do |bar| + 20.times { |i| bar.tick(set_percent: (i + 1) / 20.0) } + end + end) + end + + #: (String name) -> void + def frames(name) + write("#{name}.raw", to_stdout do + CLI::UI::Frame.open('Deploy') do + puts CLI::UI.fmt('{{v}} checked out {{blue:main}}') + CLI::UI::Frame.open('Migrate', color: :cyan) { puts '3 migrations applied' } + puts 'done' + end + end) + end + + # A full-screen prompt redrawing its selection, which a terminal discards on + # exit: the replay must keep the main screen only. + #: (String name) -> void + def alternate_screen(name) + tasks = CLI::UI.fmt("{{v}} one\n") + CLI::UI.fmt("{{v}} two\n") + stream = +'' + stream << tasks + stream << CLI::UI::ANSI.enter_alternate_screen << tasks + stream << "? Which environment? \n" + stream << CLI::UI::ANSI.cursor_up(1) << CLI::UI::ANSI.cursor_horizontal_absolute(1) + stream << "\e[K> production\n\e[K staging" << CLI::UI::ANSI.previous_lines(1) + stream << "\e[K production\n\e[K> staging" + stream << CLI::UI::ANSI.exit_alternate_screen + stream << CLI::UI.fmt("{{v}} environment: staging\n") + write("#{name}.raw", stream) + end + + #: { -> void } -> String + def to_stdout + io = StringIO.new + original = $stdout + $stdout = io + yield + io.string + ensure + $stdout = original + end +end + +namespace :replay do + desc 'Re-render the ANSI replay captures from cli-ui' + task :capture do + $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + ReplayFixtures.capture + end + + desc 'Re-derive the ANSI replay expectations from xterm.js' + task :bless do + ReplayFixtures.bless + end +end diff --git a/rakelib/unicode.rake b/rakelib/unicode.rake new file mode 100644 index 00000000..a4013165 --- /dev/null +++ b/rakelib/unicode.rake @@ -0,0 +1,140 @@ +# typed: true +# frozen_string_literal: true + +require 'digest' +require 'fileutils' +require 'net/http' +require 'uri' + +module UnicodeWidthDataGenerator + VERSION = '17.0.0' + OUTPUT = File.expand_path('../lib/cli/ui/ansi/width_data.rb', __dir__) + SOURCES = { + 'EastAsianWidth.txt' => { + url: "https://www.unicode.org/Public/#{VERSION}/ucd/EastAsianWidth.txt", + sha256: 'ea7ce50f3444a050333448dffef1cadd9325af55cbb764b4a2280faf52170a33', + }, + 'emoji-data.txt' => { + url: "https://www.unicode.org/Public/#{VERSION}/ucd/emoji/emoji-data.txt", + sha256: '2cb2bb9455cda83e8481541ecf5b6dfda66a3bb89efa3fa7c5297eccf607b72b', + }, + }.freeze + + extend self + + #: -> void + def generate + sources = SOURCES.to_h do |name, source| + data = fetch(source.fetch(:url)) + digest = Digest::SHA256.hexdigest(data) + unless digest == source.fetch(:sha256) + raise "#{name} SHA-256 mismatch: expected #{source.fetch(:sha256)}, got #{digest}" + end + + [name, data] + end + + ranges = + property_ranges(sources.fetch('EastAsianWidth.txt')) { |property| property == 'W' || property == 'F' } + + property_ranges(sources.fetch('emoji-data.txt')) { |property| property == 'Emoji_Presentation' } + + FileUtils.mkdir_p(File.dirname(OUTPUT)) + File.write(OUTPUT, render(merge(ranges))) + end + + #: (String url) -> String + def fetch(url) + uri = URI(url) + response = Net::HTTP.get_response(uri) + raise "Failed to fetch #{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + response.body + end + + #: (String data) { (String property) -> bool } -> Array[Range[Integer]] + def property_ranges(data) + data.each_line.filter_map do |line| + fields = line.split('#', 2).first.to_s.split(';', 2).map(&:strip) + next if fields.length < 2 || !yield(fields.fetch(1)) + + first, last = fields.fetch(0).split('..', 2) + Range.new(Integer(first, 16), Integer(last || first, 16)) + end + end + + #: (Array[Range[Integer]] ranges) -> Array[Range[Integer]] + def merge(ranges) + ranges.sort_by(&:begin).each_with_object([]) do |range, merged| + previous = merged.last + if previous && range.begin <= previous.end + 1 + merged[-1] = Range.new(previous.begin, [previous.end, range.end].max) + else + merged << range + end + end + end + + #: (Array[Range[Integer]] ranges) -> String + def render(ranges) + source_comments = SOURCES.map do |name, source| + "# - #{source.fetch(:url)}\n# SHA-256: #{source.fetch(:sha256)} (#{name})" + end.join("\n") + ranges_sha256 = Digest::SHA256.hexdigest(serialize(ranges)) + entries = ranges.map do |range| + " #{format_codepoint(range.begin)}..#{format_codepoint(range.end)}," + end.join("\n") + + <<~RUBY + # typed: true + # frozen_string_literal: true + + # Generated by `bundle exec rake unicode:generate_width_data`. + # Unicode version: #{VERSION} + # Unicode data © 2025 Unicode, Inc. + # Terms: https://www.unicode.org/terms_of_use.html + # Sources: + #{source_comments} + # Width ranges SHA-256: #{ranges_sha256} + # + # Do not edit this file by hand. Update the pinned version and checksums + # in rakelib/unicode.rake, then regenerate it. + + module CLI + module UI + module ANSI + module TerminalWidth + UNICODE_VERSION = '#{VERSION}' + + # Codepoints with East_Asian_Width=Wide or Fullwidth, plus those + # with Emoji_Presentation=Yes. VS16 presentation is handled by + # TerminalWidth.grapheme_width because it is a property of the + # cluster. + WIDE_RANGES = [ + #{entries} + ].freeze + end + end + end + end + RUBY + end + + #: (Array[Range[Integer]] ranges) -> String + def serialize(ranges) + ranges.map do |range| + "#{format_codepoint(range.begin)}..#{format_codepoint(range.end)}" + end.join("\n") + end + + #: (Integer codepoint) -> String + def format_codepoint(codepoint) + format('0x%04X', codepoint) + end +end + +namespace :unicode do + desc 'Regenerate terminal-width ranges from pinned Unicode data' + task :generate_width_data do + UnicodeWidthDataGenerator.generate + end +end diff --git a/test/cli/ui/ansi/replay_fixtures_test.rb b/test/cli/ui/ansi/replay_fixtures_test.rb new file mode 100644 index 00000000..e0b8ac97 --- /dev/null +++ b/test/cli/ui/ansi/replay_fixtures_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'cli/ui/ansi' + +module CLI + module UI + module ANSI + # Real cli-ui captures paired with the text xterm.js settles on. The + # expectations come from xterm.js rather than from this implementation, + # so they catch a replay that is self-consistently wrong. See + # test/fixtures/replay/README.md. + class ReplayFixturesTest < Minitest::Test + FIXTURES = File.expand_path('../../../fixtures/replay', __dir__) + CAPTURES = Dir.glob(File.join(FIXTURES, '*.raw')).sort.freeze + + CAPTURES.each do |capture| + name = File.basename(capture, '.raw') + + define_method(:"test_#{name}_replays_to_xterm_js_output") do + expected = File.read(File.join(FIXTURES, "#{name}.expected"), encoding: Encoding::UTF_8) + + assert_equal(expected, ANSI.replay(File.binread(capture))) + end + end + + # A fixture only earns its place by holding terminal history that + # replay meaningfully collapses; a regenerated capture that lost it + # would still pass the assertions above. + def test_every_capture_contains_collapsible_terminal_history + refute_empty(CAPTURES) + + CAPTURES.each do |capture| + raw = File.binread(capture) + + assert_operator( + ANSI.replay(raw).bytesize, + :<, + ANSI.strip_codes(raw.dup.force_encoding(Encoding::UTF_8)).bytesize, + "#{File.basename(capture)} holds nothing for replay to collapse", + ) + end + end + end + end + end +end diff --git a/test/cli/ui/ansi/replay_test.rb b/test/cli/ui/ansi/replay_test.rb new file mode 100644 index 00000000..6385330c --- /dev/null +++ b/test/cli/ui/ansi/replay_test.rb @@ -0,0 +1,510 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'cli/ui/ansi' + +module CLI + module UI + module ANSI + class ReplayTest < Minitest::Test + def test_plain_text_is_untouched + assert_equal("one\ntwo", Replay.render("one\ntwo")) + end + + def test_drops_color_cursor_visibility_and_hyperlinks + raw = "\e[?25l\e[0;33m*\e[0m #{CLI::UI.link("https://shopify.dev", "docs", format: false)}\e[?25h" + + assert_equal('* docs', Replay.render(raw)) + end + + def test_carriage_return_overwrites_in_place + assert_equal('byelo', Replay.render("hello\rbye")) + assert_equal('bye', Replay.render("hello\r\e[Kbye")) + end + + def test_cursor_movement_positions_text + assert_equal(' x', Replay.render("\e[4Cx")) + assert_equal('axb', Replay.render("a b\e[2Dx")) + assert_equal('xb', Replay.render("ab\e[1Gx")) + end + + # A terminal's cursor can sit below everything written so far. + def test_cursor_can_move_below_written_output + assert_equal("a\n b", Replay.render("a\e[Bb")) + assert_equal("a\nb", Replay.render("a\e[Eb")) + end + + def test_erase_line_modes + assert_equal('keep', Replay.render("keep drop\e[5G\e[0K")) + assert_equal(' drop', Replay.render("keep drop\e[5G\e[1K")) + assert_equal('', Replay.render("keep drop\e[2K")) + end + + # Trailing whitespace is trimmed from every line: a terminal renders + # nothing there, so blanks left by padding or erasure are not content. + def test_trailing_whitespace_is_trimmed + assert_equal('ab', Replay.render('ab ')) + assert_equal("a\n b", Replay.render("a \n b ")) + end + + # Messages printed above a running spin group arrive as inserted lines. + def test_insert_and_delete_lines + assert_equal("note\nkept", Replay.render("kept\e[1G\e[1Lnote\n")) + assert_equal('second', Replay.render("first\nsecond\e[2A\e[1M")) + end + + # Inserting while the cursor is still below the written output must not + # punch holes into the screen. + def test_insert_below_written_output + assert_equal("\n\nx\n", Replay.render("\e[2B\e[Lx")) + end + + def test_tab_advances_to_the_next_tab_stop + assert_equal('a b', Replay.render("a\tb")) + assert_equal('axttttttb', Replay.render("a\tb\e[2Gxtttttt")) + end + + def test_bell_prints_nothing + assert_equal('xy', Replay.render("x\ay")) + end + + def test_cursor_save_and_restore + assert_equal("AC\nB", Replay.render("A\e[s\nB\e[uC")) + assert_equal("AC\nB", Replay.render("A\e7\nB\e8C")) + end + + # IND and RI feed and reverse-feed a line keeping the column, and + # NEL starts a new one: escape-level movement a repaint can use. + def test_escape_line_movement + assert_equal("a\n b", Replay.render("a\eDb")) + assert_equal("a\nb", Replay.render("a\eEb")) + assert_equal("ax\nc", Replay.render("ab\nc\eMx")) + end + + # CSI s with parameters is DECSLRM, setting scroll margins, not a + # cursor save; margins are viewport-dependent and ignored. + def test_parameterized_save_cursor_is_not_misread + assert_equal('abZY', Replay.render("ab\e[sX\e[10;70sY\e[uZ")) + end + + # A restore with no prior save homes the cursor, as xterm does. + def test_restore_without_save_homes_the_cursor + assert_equal("?cz\nab", Replay.render("xyz\nab\e[u?c")) + end + + # A stray ESC aborts nothing but itself: the sequence after it survives. + def test_stray_escape_does_not_eat_a_following_sequence + assert_equal(' x', Replay.render("\e\e[5Cx")) + end + + # An intermediate byte or a private parameter marker selects a + # different command than the final byte alone: \e[1 A is + # scroll-right, not cursor-up. + def test_nonstandard_csi_sequences_are_not_dispatched + assert_equal("x\nyz", Replay.render("x\ny\e[1 Az")) + assert_equal('x', Replay.render("\e[>5Cx")) + end + + # DCS, SOS, PM, and APC payloads are consumed, never displayed. + def test_control_string_payloads_do_not_leak + assert_equal('ab', Replay.render("a\ePpayload\e\\b")) + assert_equal('ab', Replay.render("a\e_hidden\e\\b")) + assert_equal('ab', Replay.render("a\ePstuff\x18b")) + assert_equal('a', Replay.render("a\ePcut off")) + end + + def test_nonprinting_controls_are_dropped + assert_equal('ab', Replay.render("a\x18b")) + assert_equal('ab', Replay.render("a\x00b")) + assert_equal('ab', Replay.render("a\x7fb")) + end + + # VT and FF feed a line but keep the column: the tty driver's ONLCR + # translation, which turns \n into \r\n, applies to neither. + def test_vertical_tab_and_form_feed_advance_a_line + assert_equal("a\n b", Replay.render("a\vb")) + assert_equal("a\n b", Replay.render("a\fb")) + end + + # A C0 control embedded in a CSI sequence is executed and collection + # continues, as a real terminal's parser does. + def test_embedded_controls_execute_inside_csi_sequences + assert_equal('red', Replay.render("\e[3\a1mred")) + assert_equal('red', Replay.render("\e[3\b1mred")) + assert_equal("x\nred", Replay.render("x\e[3\n1mred")) + end + + # Controls embedded after an ESC intermediate do not end its sequence: + # C0 controls execute, DEL is ignored, and the eventual final is still + # consumed. + def test_embedded_controls_do_not_end_escape_intermediate_sequences + assert_equal('az', Replay.render("ab\e(\bBz")) + assert_equal('Az', Replay.render("A\e(\x7fBz")) + end + + # A parameter byte arriving after an intermediate puts a terminal in + # its ignore state: the sequence is consumed but not executed. + def test_out_of_order_csi_bytes_ignore_the_sequence + assert_equal('red', Replay.render("\e[1 5mred")) + end + + # Erase-line modes a terminal doesn't define do nothing. + def test_invalid_erase_line_modes_are_ignored + assert_equal('keep drop', Replay.render("keep drop\e[5G\e[3K")) + end + + # The alternate screen holds a full-screen UI -- a prompt, a pager -- + # that a terminal discards on exit: its content never enters + # scrollback, so a replay withholds it too, cursor restored to where + # the main screen left it. + def test_alternate_screen_content_is_discarded + ['47', '1047', '1049'].each do |mode| + assert_equal( + 'before after', + Replay.render("before \e[?#{mode}hfull-screen ui\e[?#{mode}lafter"), + "DEC private mode #{mode}", + ) + end + # A capture cut off inside the alternate screen never saw the + # exit, but its content still never reached scrollback. + assert_equal('kept', Replay.render("kept\e[?1049hlost")) + # An unmatched exit and a doubled enter change nothing. + assert_equal('ab', Replay.render("a\e[?1049lb")) + assert_equal('ab', Replay.render("a\e[?1049h\e[?1049hx\e[?1049lb")) + # DECSET and DECRST apply every mode in a semicolon-separated list. + assert_equal('ab', Replay.render("a\e[?25;1049hLOST\e[?25;1049lb")) + end + + # StdoutRouter's in_alternate_screen re-prints everything captured + # so far inside the alternate screen; a replay that ignored ?1049 + # would show that content twice. + def test_alternate_screen_does_not_duplicate_a_reprinted_capture + prior = "✔ one\n✔ two\n" + stream = prior + + ANSI.enter_alternate_screen + prior + '? Choose: ' + + ANSI.exit_alternate_screen + 'done' + + assert_equal("✔ one\n✔ two\ndone", Replay.render(stream)) + end + + # Absolute positioning and display erasure are viewport-relative, and + # a capture does not record scrolling: there is no way to know which + # buffer row was screen row 1. Ignoring them is policy, not oversight; + # mapping \e[H to row zero would overwrite scrolled-off content. + def test_viewport_dependent_sequences_are_ignored + assert_equal("old\nlinenew", Replay.render("old\nline\e[Hnew")) + assert_equal("old\nlinenew", Replay.render("old\nline\e[2;3fnew")) + assert_equal('kept', Replay.render("kept\e[2J")) + end + + def test_viewport_dependent_sequences_are_reported + sequences = [ + "\e[H", # CUP + "\e[2;3f", # HVP + "\e[3d", # VPA + "\e[2J", # ED + "\e[2S", # SU + "\e[2T", # SD + "\e[1;20r", # DECSTBM + "\e[10;70s", # DECSLRM + "\e[2 @", # SL + "\e[2 A", # SR + ] + + sequences.each do |sequence| + diagnostics = {} + + ANSI.replay("kept#{sequence}", diagnostics: diagnostics) + + assert_equal(true, diagnostics[:viewport_operations_seen], sequence.inspect) + end + end + + def test_supported_repaint_sequences_do_not_report_viewport_operations + diagnostics = {} + + ANSI.replay("one\ntwo\e[1A\e[1G\e[2Kdone", diagnostics: diagnostics) + + assert_empty(diagnostics) + end + + def test_unknown_and_incomplete_escapes_are_dropped + assert_equal('text', Replay.render("\e(Btext\e")) + assert_equal('ok', Replay.render("ok\e[31")) + assert_equal('ok', Replay.render("ok\e]0;a title")) + end + + # CAN aborts the sequence; what follows is text again. + def test_cancelled_osc_releases_following_text + assert_equal('ok visible', Replay.render("ok \e]0;title\x18visible")) + end + + # An aborted sequence's payload was never displayed, and an aborting + # ESC starts its own sequence. + def test_aborted_sequences_do_not_leak_their_payload + assert_equal('ok red', Replay.render("ok \e]0;title\e[31mred")) + assert_equal('m', Replay.render("\e[3\x18m")) + assert_equal(' x', Replay.render("\e[3\e[2Cx")) + end + + # Captures are routinely read in binary mode, and columns are + # characters, not bytes: every input is decoded as UTF-8, replacing + # what doesn't decode. + def test_input_is_decoded_as_utf8 + assert_equal('éQ', Replay.render("éx\e[2GQ".b)) + assert_equal('café', Replay.render('café'.dup.force_encoding(Encoding::US_ASCII))) + assert_equal('café', Replay.render('café'.encode(Encoding::ISO_8859_1))) + assert_equal('a�b', Replay.render("a\xFFb".b)) + end + + # Wide glyphs occupy two columns, so column-addressed writes land + # where the terminal put the text. A ZWJ sequence is one cluster: + # one glyph, two columns. + def test_wide_glyphs_occupy_two_columns + assert_equal('🔧X', Replay.render("🔧b\e[3GX")) + assert_equal('ab', Replay.render("👩‍💻\rab")) + end + + # Overwriting either half of a wide glyph blanks the other, as a + # terminal blanks a glyph it can no longer show whole. + def test_overwriting_half_a_wide_glyph_blanks_the_other_half + assert_equal('a x', Replay.render("🔧x\ra")) + assert_equal('🔧', Replay.render("ab\r🔧")) + end + + # A combining mark rides its base character: one cluster, one column. + def test_combining_marks_share_their_column + assert_equal('xZ', Replay.render("e\u0301Z\rx")) + assert_equal("e\u0301X", Replay.render("e\u0301Z\e[2GX")) + end + + # Presentation sequences do not interrupt a grapheme a terminal is + # assembling. Re-segmenting across them also preserves a wide base and + # lets a variation selector widen the glyph before the next write. + def test_graphemes_continue_across_presentation_sequences + assert_equal("e\u0301X", Replay.render("e\e[31m\u0301Z\e[2GX")) + assert_equal("🔧\u0301X", Replay.render("🔧\e[31m\u0301b\e[3GX")) + assert_equal('⚠️X', Replay.render("⚠\e[31m️b\e[3GX")) + assert_equal('👩‍💻X', Replay.render("👩\e[31m‍💻b\e[3GX")) + end + + # A leading mark with no preceding cell prints nothing. If a cursor + # move left an implicit blank before it, the mark combines with that + # blank without advancing the cursor. + def test_leading_combining_marks_need_a_preceding_cell + assert_equal('Z', Replay.render("\u0301Z")) + assert_equal("a \u0301Z", Replay.render("a\e[C\u0301Z")) + end + + # The writer's own output can run past MAX_COLUMN; the cap holds back + # only columns that control sequences conjure. + def test_long_lines_keep_relative_movement_working + line = 'x' * (Replay::MAX_COLUMN + 904) + + replayed = Replay.render("#{line}\b\bX") + + assert_equal(line.length, replayed.length) + assert_equal(line.length - 2, replayed.index('X')) + end + + def test_write_once_rows_stay_compact_until_they_are_edited + screen = Replay::Screen.new + rows = ['x' * 80, '界' * 40, '👩‍💻' * 40] + 100.times do |index| + screen.write(rows.fetch(index % rows.length)) + screen.erase_line(0) + screen.newline + end + + lines = screen.instance_variable_get(:@lines) + assert(lines.all?(String)) + + screen.move_rows(-1) + screen.carriage_return + screen.write('y') + + assert_instance_of(Array, lines.fetch(-2)) + assert(lines.each_with_index.all? { |line, index| index == 99 || line.is_a?(String) }) + end + + def test_control_conjured_rows_are_bounded + moves = Replay.render(("\e[9999999B" * 1000) + 'x') + inserts = Replay.render("\e[1024L" * 200) + # A newline after a cursor move must not hand the budget back. + interleaved = Replay.render("\e[1024B\n" * 20) + + assert_operator(moves.lines.length, :<=, Replay::MAX_PADDING + 1) + assert_operator(inserts.lines.length, :<=, Replay::MAX_PADDING + 1) + assert_operator(interleaved.lines.length, :<=, Replay::MAX_PADDING + 20) + assert_equal(Replay::MAX_COLUMN + 1, Replay.render("#{"\e[1024C" * 8}x").length) + end + + # Ordinary output is content, not garbage: it never spends the cap. + def test_long_captures_keep_insert_line_working + capture = "line\n" * (Replay::MAX_PADDING + 10) + + replayed = Replay.render("#{capture}kept\e[1G\e[1Lnote\n") + + assert_equal("note\nkept", replayed.lines.last(2).join.chomp) + end + + # Every message printed above a running spin group inserts a row + # that immediately receives text: content, not padding. Were those + # rows charged to the cap, inserts past it would silently no-op and + # later messages would overwrite the task lines below. + def test_puts_above_messages_are_never_charged_to_the_cap + tasks = "✓ first\n✓ second" + notes = "\e[1Lnote\n" * (Replay::MAX_PADDING + 5) + + replayed = Replay.render("#{tasks}\e[1A\e[1G#{notes}") + + assert_equal(['✓ first', '✓ second'], replayed.lines.last(2).map(&:chomp)) + end + + # A capture can hold anything: truncated sequences, binary noise, + # aborts landing mid-collection. Whatever the bytes, a replay must + # not raise, must return valid UTF-8, and must not leak control + # bytes into the output. + def test_fuzz_arbitrary_streams_replay_safely_and_both_csi_paths_agree + rng = Random.new(20260811) + fragments = [ + "\e[", + "\e]", + "\eP", + "\e\\", + "\e", + "\x18", + "\x1a", + "\a", + "\r", + "\n", + "\t", + ';', + '?', + ' ', + 'm', + 'A', + 'K', + 'text', + 'é', + '⠧', + "\e[31m", + "\e[2A", + "\e[1G", + "\e[2K", + "\e[1L", + "\e[2M", + "\e[s", + "\e[u", + "\e[3G", + "\e[2C", + "\b", + "\e[?47h", + "\e[?47l", + "\e[?1047h", + "\e[?1047l", + "\e[?1049h", + "\e[?1049l", + ] + streams = [ + "hello\r\e[Kbye", + "a b\e[2Dx\e[1Gy", + "\e[?25l\e[0;33m* done\e[0m\e[?25h", + "x\ny\e[1 Az", + "\e[3\a1mred", + "ok\e[31", + "⚠\e[31m️b\e[3GX", + "👩\e[31m‍💻b\e[3GX", + "界Z\e[2GX", + "\u0301Z", + "a\e[C\u0301Z", + "界\tX", + ] + 3000.times do + stream = ''.b + rng.rand(40..120).times do + stream << (rng.rand(3).zero? ? rng.bytes(rng.rand(1..6)) : fragments.sample(random: rng).b) + end + streams << stream + end + + expected = streams.map do |stream| + replayed = Replay.render(stream) + + assert_predicate(replayed, :valid_encoding?) + assert_nil(replayed[/[\x00-\x09\x0b-\x1f\x7f]/], "control byte leaked replaying #{stream.inspect}") + replayed + end + + original = Replay::CSI_BODY + Replay.send(:remove_const, :CSI_BODY) + Replay.const_set(:CSI_BODY, /(?!)/) # never matches + + streams.each_with_index do |stream, index| + assert_equal(expected.fetch(index), Replay.render(stream), "CSI paths diverged replaying #{stream.inspect}") + end + + Replay.send(:remove_const, :CSI_BODY) + Replay.const_set(:CSI_BODY, original) + + original_screen = Replay::Screen + forced_cell_screen = Class.new(original_screen) do + def write(text) + materialize(@row) + promote(@row) + super + end + + def erase_line(mode) + promote(@row) if @row < @lines.length + super + end + end + Replay.send(:remove_const, :Screen) + Replay.const_set(:Screen, forced_cell_screen) + + streams.each_with_index do |stream, index| + assert_equal(expected.fetch(index), Replay.render(stream), "write paths diverged replaying #{stream.inspect}") + end + ensure + if original && Replay::CSI_BODY != original + Replay.send(:remove_const, :CSI_BODY) + Replay.const_set(:CSI_BODY, original) + end + if original_screen && Replay::Screen != original_screen + Replay.send(:remove_const, :Screen) + Replay.const_set(:Screen, original_screen) + end + end + + def test_replays_a_spin_group_to_one_line_per_task + repaint = Queue.new + sink_class = Class.new(StringIO) do + define_method(:print) do |*args| + super(*args).tap do + if !defined?(@repaint_signaled) && string.match?(/\e\[\d*A/) + @repaint_signaled = true + 2.times { repaint << true } + end + end + end + end + sink = sink_class.new + CLI::UI::StdoutRouter.ensure_activated + group = CLI::UI::SpinGroup.new(auto_debrief: false) + group.add('first') { repaint.pop(timeout: 2) } + group.add('second') { repaint.pop(timeout: 2) } + assert(group.wait(to: sink)) + out = sink.string + + replayed = ANSI.replay(out).lines.map(&:chomp).reject(&:empty?) + + assert_match(/\e\[\d*A/, out, 'expected the raw capture to hold cursor repaints') + assert_equal(2, replayed.length) + assert_match(/first/, replayed.fetch(0)) + assert_match(/second/, replayed.fetch(1)) + end + end + end + end +end diff --git a/test/cli/ui/ansi/terminal_width_test.rb b/test/cli/ui/ansi/terminal_width_test.rb new file mode 100644 index 00000000..b61a1260 --- /dev/null +++ b/test/cli/ui/ansi/terminal_width_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'digest' +require 'test_helper' +require 'cli/ui/ansi/terminal_width' + +module CLI + module UI + module ANSI + class TerminalWidthTest < Minitest::Test + def test_generated_ranges_are_current_sorted_and_unchanged + assert_equal('17.0.0', TerminalWidth::UNICODE_VERSION) + TerminalWidth::WIDE_RANGES.each_cons(2) do |left, right| + assert_operator(left.end, :<, right.begin) + end + + width_data = File.read(File.expand_path('../../../../lib/cli/ui/ansi/width_data.rb', __dir__)) + expected = width_data[/^# Width ranges SHA-256: ([0-9a-f]{64})$/, 1] + serialized = TerminalWidth::WIDE_RANGES.map do |range| + format('0x%04X..0x%04X', range.begin, range.end) + end.join("\n") + + assert_equal(expected, Digest::SHA256.hexdigest(serialized)) + end + + def test_grapheme_width_matches_terminal_columns + assert_equal(0, TerminalWidth.grapheme_width("\n")) + assert_equal(1, TerminalWidth.grapheme_width("e\u0301")) + assert_equal(1, TerminalWidth.grapheme_width("\u{26a0}")) + assert_equal(2, TerminalWidth.grapheme_width("\u{26a0}\u{fe0f}")) + assert_equal(2, TerminalWidth.grapheme_width('漢')) + assert_equal(2, TerminalWidth.grapheme_width('👩‍💻')) + + # U+1FA8A TROMBONE was added with Emoji 17.0. + assert_equal(2, TerminalWidth.grapheme_width("\u{1fa8a}")) + end + end + end + end +end diff --git a/test/fixtures/replay/.gitattributes b/test/fixtures/replay/.gitattributes new file mode 100644 index 00000000..4a5ef175 --- /dev/null +++ b/test/fixtures/replay/.gitattributes @@ -0,0 +1,4 @@ +# Captures and expectations are byte-exact: keep git out of their line endings +# and whitespace rules. +*.raw -text -whitespace +*.expected -text -whitespace diff --git a/test/fixtures/replay/.gitignore b/test/fixtures/replay/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/test/fixtures/replay/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/test/fixtures/replay/README.md b/test/fixtures/replay/README.md new file mode 100644 index 00000000..c292511a --- /dev/null +++ b/test/fixtures/replay/README.md @@ -0,0 +1,55 @@ +# ANSI replay fixtures + +Each pair is a real cli-ui capture (`.raw`) and the text xterm.js settles on +after processing it (`.expected`). + +The point is the provenance of `.expected`: it is **not** produced by +`ANSI.replay`. It comes from [xterm.js][], the emulator behind VS Code, so the +fixtures assert agreement with an independent terminal emulator rather than +agreement with ourselves. They are xterm.js fixtures, not a claim that every +terminal emulator behaves identically. `replay_fixtures_test.rb` only reads the +committed files, so neither node nor xterm.js is needed to run the suite. + +## Regenerating + +```sh +bundle exec rake replay:capture # re-render the .raw captures from cli-ui +bundle exec rake replay:bless # re-derive the .expected files from xterm.js +``` + +`replay:bless` needs node and the locked dependencies installed in this +directory: + +```sh +npm ci +``` + +Captures are timing-dependent: re-running `replay:capture` yields a different +number of spinner frames, which is fine — bless afterwards. Review a blessed +diff before committing it. If `.expected` changes without an intended behavior +change, xterm.js is telling you something. + +## Scope + +Only captures that fit the oracle's viewport can be blessed this way. `oracle.js` +uses a 200x500 screen with 100k lines of scrollback, so nothing in these fixtures +scrolls; a capture that scrolled would need screen coordinates that replay +deliberately does not model. + +Four differences from xterm.js are known and expected, so don't add a fixture +that leans on them: + +- **Viewport operations.** Absolute positioning (CUP, HVP), display erasure + (ED), scroll margins (DECSLRM) and scroll-left/right are ignored by replay by + design; xterm.js applies them. Callers can detect such streams through the + optional `diagnostics[:viewport_operations_seen]` flag. +- **VT and FF.** The oracle's `convertEol` homes the column for `\n`, `\v` and + `\f` alike. A tty's ONLCR translates only `\n`, which is what replay models. +- **Trailing blank rows.** A fixed-height buffer cannot say whether a blank row + below the cursor is content, so a capture ending in an inserted blank line + reads one line shorter in the oracle. +- **Graphemes split by an escape sequence.** Replay re-segments `e\e[31m\u0301` + into one cell; xterm.js resets its cluster state at the sequence and gives the + mark its own cell. + +[xterm.js]: https://github.com/xtermjs/xterm.js diff --git a/test/fixtures/replay/alternate_screen_prompt.expected b/test/fixtures/replay/alternate_screen_prompt.expected new file mode 100644 index 00000000..09104482 --- /dev/null +++ b/test/fixtures/replay/alternate_screen_prompt.expected @@ -0,0 +1,3 @@ +✓ one +✓ two +✓ environment: staging diff --git a/test/fixtures/replay/alternate_screen_prompt.raw b/test/fixtures/replay/alternate_screen_prompt.raw new file mode 100644 index 00000000..9fc2db22 --- /dev/null +++ b/test/fixtures/replay/alternate_screen_prompt.raw @@ -0,0 +1,8 @@ +✓ one +✓ two +[?1049h✓ one +✓ two +? Which environment? +> production + staging production +> staging[?1049l✓ environment: staging diff --git a/test/fixtures/replay/frame_nested.expected b/test/fixtures/replay/frame_nested.expected new file mode 100644 index 00000000..ad719780 --- /dev/null +++ b/test/fixtures/replay/frame_nested.expected @@ -0,0 +1,7 @@ +┏━━ Deploy ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✓ checked out main +┃┏━━ Migrate ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3 migrations applied +┃┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━ +done +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━ diff --git a/test/fixtures/replay/frame_nested.raw b/test/fixtures/replay/frame_nested.raw new file mode 100644 index 00000000..208aaa2d --- /dev/null +++ b/test/fixtures/replay/frame_nested.raw @@ -0,0 +1,7 @@ +[?25l ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┏━━ Deploy [?25h +✓ checked out main +┃[?25l ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┏━━ Migrate [?25h +3 migrations applied +┃[?25l ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┗━━ (0.0s) [?25h +done +[?25l ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┗━━ (0.0s) [?25h diff --git a/test/fixtures/replay/oracle.js b/test/fixtures/replay/oracle.js new file mode 100644 index 00000000..983eda01 --- /dev/null +++ b/test/fixtures/replay/oracle.js @@ -0,0 +1,45 @@ +// xterm.js oracle for the ANSI replay fixtures. +// +// Feeds a capture to xterm.js headless and prints the text its buffer +// settled on, which `bundle exec rake replay:bless` writes to the matching +// .expected file. See README.md. +// +// npm ci +// node oracle.js + +const { Terminal } = require('@xterm/headless'); +const { UnicodeGraphemesAddon } = require('@xterm/addon-unicode-graphemes'); +const fs = require('fs'); + +// convertEol matches the tty driver's ONLCR, which a capture records without. +// The screen is large enough that fixtures neither wrap nor scroll. +function settle(data, cols, rows) { + return new Promise((resolve) => { + const term = new Terminal({ cols, rows, scrollback: 100000, convertEol: true, allowProposedApi: true }); + term.loadAddon(new UnicodeGraphemesAddon()); + term.unicode.activeVersion = '15-graphemes'; + term.write(data, () => { + const buf = term.buffer.active; + const lines = []; + for (let i = 0; i < buf.length; i++) { + lines.push(buf.getLine(i).translateToString(true).replace(/\s+$/, '')); + } + // A capture's output ends at the cursor, or at the last row holding + // content when the cursor was left above it. + let last = buf.baseY + buf.cursorY; + for (let i = lines.length - 1; i > last; i--) { + if (lines[i] !== '') { + last = i; + break; + } + } + resolve(lines.slice(0, last + 1).join('\n')); + }); + }); +} + +(async () => { + const cols = Number(process.env.COLS || 200); + const rows = Number(process.env.ROWS || 500); + process.stdout.write(await settle(fs.readFileSync(process.argv[2]), cols, rows)); +})(); diff --git a/test/fixtures/replay/package-lock.json b/test/fixtures/replay/package-lock.json new file mode 100644 index 00000000..cb23376b --- /dev/null +++ b/test/fixtures/replay/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "cli-ui-replay-oracle", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cli-ui-replay-oracle", + "version": "0.0.0", + "dependencies": { + "@xterm/addon-unicode-graphemes": "0.4.0", + "@xterm/headless": "6.0.0" + } + }, + "node_modules/@xterm/addon-unicode-graphemes": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.4.0.tgz", + "integrity": "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==" + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "workspaces": [ + "addons/*" + ] + } + } +} diff --git a/test/fixtures/replay/package.json b/test/fixtures/replay/package.json new file mode 100644 index 00000000..84ffa528 --- /dev/null +++ b/test/fixtures/replay/package.json @@ -0,0 +1,10 @@ +{ + "name": "cli-ui-replay-oracle", + "version": "0.0.0", + "private": true, + "description": "Pinned xterm.js oracle for ANSI replay fixtures", + "dependencies": { + "@xterm/addon-unicode-graphemes": "0.4.0", + "@xterm/headless": "6.0.0" + } +} diff --git a/test/fixtures/replay/progress_bar.expected b/test/fixtures/replay/progress_bar.expected new file mode 100644 index 00000000..d07af50d --- /dev/null +++ b/test/fixtures/replay/progress_bar.expected @@ -0,0 +1,3 @@ +Building +Building 5% + 100% diff --git a/test/fixtures/replay/progress_bar.raw b/test/fixtures/replay/progress_bar.raw new file mode 100644 index 00000000..fe87f491 --- /dev/null +++ b/test/fixtures/replay/progress_bar.raw @@ -0,0 +1,43 @@ +[?25lBuilding +   5%  +Building +   10%  +Building +   15%  +Building +   20%  +Building +   25%  +Building +   30%  +Building +   35%  +Building +   40%  +Building +   45%  +Building +   50%  +Building +   55%  +Building +   60%  +Building +   65%  +Building +   70%  +Building +   75%  +Building +   80%  +Building +   85%  +Building +   90%  +Building +   95%  +Building +  100% +Building +  100% +[?25h \ No newline at end of file diff --git a/test/fixtures/replay/spin_group.expected b/test/fixtures/replay/spin_group.expected new file mode 100644 index 00000000..7cce5151 --- /dev/null +++ b/test/fixtures/replay/spin_group.expected @@ -0,0 +1,3 @@ +✓ install dependencies +✓ compile assets +✓ run migrations diff --git a/test/fixtures/replay/spin_group.raw b/test/fixtures/replay/spin_group.raw new file mode 100644 index 00000000..b221b1c7 --- /dev/null +++ b/test/fixtures/replay/spin_group.raw @@ -0,0 +1,4 @@ +⠋ install dependencies +⧖ compile assets +⧖ run migrations + ⠙  ⠙  ⠙  ⠹  ⠹  ⠹  ⠸  ⠸  ⠸  ✓  ✓  ✓  \ No newline at end of file diff --git a/test/fixtures/replay/spin_group_retitled.expected b/test/fixtures/replay/spin_group_retitled.expected new file mode 100644 index 00000000..77c8b29e --- /dev/null +++ b/test/fixtures/replay/spin_group_retitled.expected @@ -0,0 +1 @@ +✓ bundle install (gem 3) diff --git a/test/fixtures/replay/spin_group_retitled.raw b/test/fixtures/replay/spin_group_retitled.raw new file mode 100644 index 00000000..3a52b0bc --- /dev/null +++ b/test/fixtures/replay/spin_group_retitled.raw @@ -0,0 +1,2 @@ +⧖ bundle install + ⠙ bundle install (gem 1)  ⠹ bundle install (gem 2)  ⠸ bundle install (gem 3)  ✓  \ No newline at end of file diff --git a/test/fixtures/replay/spin_group_wide_glyphs.expected b/test/fixtures/replay/spin_group_wide_glyphs.expected new file mode 100644 index 00000000..61eac4bf --- /dev/null +++ b/test/fixtures/replay/spin_group_wide_glyphs.expected @@ -0,0 +1,2 @@ +✓ 日本語のタスク +✓ 🔧 rebuild native extensions diff --git a/test/fixtures/replay/spin_group_wide_glyphs.raw b/test/fixtures/replay/spin_group_wide_glyphs.raw new file mode 100644 index 00000000..f958e4d7 --- /dev/null +++ b/test/fixtures/replay/spin_group_wide_glyphs.raw @@ -0,0 +1,3 @@ +⧖ 日本語のタスク +⧖ 🔧 rebuild native extensions + ⠙  ⠙  ⠹  ⠹  ✓  ✓  \ No newline at end of file