parallel_matrix_formatter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e0b6ef7c5f8b705474c885a007295e1f1d7ffb20e4cae852f8daea1ffe2502ea
4
+ data.tar.gz: 65c7d8f0c19dc66dc2bee3c2961a3dd68379c2357903dc1214129deac3aac1bd
5
+ SHA512:
6
+ metadata.gz: c087a648912b2cc60119dba2cc86d7f75a5139654777cc4b940c0a226805284ea0d4dc34366c81cd1f549bb0e92ed7626bd21f013f89fbd469022ee88320a78f
7
+ data.tar.gz: 0b81a551ef03313bffa728a0cc93f26280c4940c3ea3666819b6ee61ca21164e7fb138c04b19171a64b6876a498cf55457a061ad905d60002136a501abc3a2ad
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-10-03
9
+
10
+ ### Added
11
+ - `ParallelMatrixFormatter::Formatter`, an RSpec formatter for suites run with `parallel_split_test`;
12
+ it also works with a single `rspec` process.
13
+ - One shared Matrix-style display: a progress line (time plus one percentage column per process, padded with
14
+ random katakana "rain") and one colored status symbol per finished example.
15
+ - Orchestrator in process 1 that collects the results of all processes over a UNIX socket and, once every process
16
+ has reported or disconnected, prints a consolidated RSpec-style summary: all failures with messages and
17
+ backtraces, totals, wall-clock and summed process time, and `rspec ./path:line` rerun commands.
18
+ - Yellow warning naming any process that disconnected without sending a summary (for example after a crash).
19
+ - YAML configuration with defaults in `config/parallel_matrix_formatter.yml`: `suppress_output`, `digits`,
20
+ `progress_update` (`always`, `interval_seconds`, `percent_threshold`), `progress_line` and `example_status`
21
+ (formats, symbols, colors, column width and alignment).
22
+ - Project overrides in `config/parallel_matrix_formatter.yml` or `parallel_matrix_formatter.yml`, or in the file
23
+ named by `PARALLEL_MATRIX_FORMATTER_CONFIG`; missing keys fall back to the defaults (deep merge).
24
+ - Output suppression (`suppress_output: true` by default): STDOUT and STDERR of every process are reopened to
25
+ `/dev/null`, and the formatter keeps a private copy of the original stdout for the display.
26
+ - `parallel_matrix_formatter/silence` file to silence application boot output
27
+ (`RUBYOPT="-rparallel_matrix_formatter/silence"`).
28
+ - `NO_COLOR` environment variable support.
29
+ - `demo/matrix_demo.rb` to preview the display without a test suite.
30
+ - GitHub Actions workflow running specs and RuboCop on Ruby 3.2 to 4.0.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Vovka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # ParallelMatrixFormatter
2
+
3
+ An RSpec formatter for suites run with [`parallel_split_test`](https://github.com/grosser/parallel_split_test).
4
+ Instead of interleaved output from every process, it prints one shared Matrix-style display: a progress line with
5
+ the percentage of each process surrounded by falling katakana "rain", a colored symbol for every finished example,
6
+ and, at the end, a single consolidated RSpec-style summary with all failures from all processes.
7
+
8
+ ## What you are looking at
9
+
10
+ ![Four processes running a suite as Matrix digital rain](docs/images/rain.png)
11
+
12
+ A suite of 480 examples split across four processes. Nothing on this screen is a log line: every glyph is a
13
+ piece of the test run.
14
+
15
+ - **The rain.** Each row starts with a progress line: the time, then one column per process. A column is ten
16
+ characters wide, with the percentage of that process in red and random green half-width katakana filling the
17
+ rest. The katakana are drawn afresh for every row, so as the rows stack up the columns flicker and fall like
18
+ the digital rain, while the red percentages climb from 1% to 100% inside it.
19
+ - **Passed examples.** After the progress line, every finished example adds one more symbol to the row, in the
20
+ order the results arrive from all processes. A passed example is one random green katakana, so a healthy suite
21
+ simply keeps raining.
22
+ - **Failed examples.** A failure is the same kind of glyph, but red. It is a glitch in the Matrix: easy to miss
23
+ if you are not looking, impossible to unsee once you are.
24
+ - **Pending examples.** A pending or skipped example becomes a 🥄. Do not try to run the pending test, that is
25
+ impossible. Instead, only try to realize the truth: there is no spoon.
26
+ - **Silence.** Every example of this suite prints to stdout. None of it reaches the screen, see
27
+ [Output suppression](#output-suppression).
28
+
29
+ Every row has a column for every process; a process that has not reported yet shows 0%, as three of them do in
30
+ the first row. By default a new progress line is printed once a minute, and once more when every process reaches
31
+ 100%; this run uses `interval_seconds: 2` so the short demo suite produces enough rows.
32
+
33
+ When the last process finishes, the rain stops and one consolidated report follows, in the style of RSpec's own:
34
+ the failures of all processes with their messages in red and backtraces in cyan, the wall-clock time next to the
35
+ time summed across processes, the totals (red when something failed, yellow when examples are only pending, green
36
+ otherwise) and the commands to rerun the failures. The first line and the closing `Summary:` block are printed by
37
+ `parallel_split_test` itself.
38
+
39
+ ![A complete run: rain followed by the consolidated summary](docs/images/full_run.png)
40
+
41
+ The time and the percentages can go down the rabbit hole too. With `digits: "ロイクヨムラレヌメワ"` each digit 0-9 is
42
+ replaced by the character at that position:
43
+
44
+ ![Katakana digits](docs/images/katakana_digits.png)
45
+
46
+ All symbols, colors, widths and the update frequency are configurable, see [Configuration](#configuration).
47
+
48
+ ## Installation
49
+
50
+ Add the gem to your Gemfile:
51
+
52
+ ```ruby
53
+ gem 'parallel_matrix_formatter', group: :test
54
+ ```
55
+
56
+ and run `bundle install`.
57
+
58
+ Requirements: Ruby 3.2 or newer, `rspec-core` 3.x. The processes talk over UNIX sockets, so Linux and macOS are
59
+ supported; Windows is not.
60
+
61
+ ## Usage
62
+
63
+ ```sh
64
+ bundle exec parallel_split_test --format ParallelMatrixFormatter::Formatter spec
65
+ ```
66
+
67
+ The formatter also works with a single process:
68
+
69
+ ```sh
70
+ bundle exec rspec --format ParallelMatrixFormatter::Formatter
71
+ ```
72
+
73
+ The output looks like this (colors omitted):
74
+
75
+ ```
76
+ 17:04:13 キコシ89%レミエ、キヲサ92%クェィケ アイウ
77
+ ...
78
+
79
+ Failures:
80
+
81
+ 1) Widget does the thing
82
+ Failure/Error: expect(actual).to eq(expected)
83
+ ...
84
+
85
+ Finished in 12.3 seconds (22.8 seconds across processes)
86
+ 8 examples, 2 failures, 2 pending
87
+
88
+ Failed examples:
89
+
90
+ rspec ./spec/widget_spec.rb:10 # Widget does the thing
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ The defaults live in [`config/parallel_matrix_formatter.yml`](config/parallel_matrix_formatter.yml). To change
96
+ them, create `config/parallel_matrix_formatter.yml` or `parallel_matrix_formatter.yml` in your project root, or
97
+ point the `PARALLEL_MATRIX_FORMATTER_CONFIG` environment variable at a file. You only need to list the keys you
98
+ change; everything else falls back to the defaults.
99
+
100
+ The full schema, with the default values:
101
+
102
+ ```yaml
103
+ # Redirect STDOUT and STDERR of every test process to /dev/null.
104
+ suppress_output: true
105
+
106
+ # Ten characters that replace the digits 0-9 in the time and the percentages.
107
+ # Leave empty to keep plain digits.
108
+ digits: ""
109
+
110
+ # When to print a fresh progress line. The first rule that applies wins.
111
+ progress_update:
112
+ always: false # after every example
113
+ interval_seconds: 60 # at most once per interval, plus once when every process is done; 0 disables
114
+ percent_threshold: 0 # whenever any process advances by this many percent; 0 disables
115
+
116
+ # The line showing the progress of every process.
117
+ progress_line:
118
+ format: "\n{time} {columns} " # placeholders: {time}, {columns}
119
+ column: # one column per process
120
+ width: 10
121
+ align: center # left, center or right
122
+ color: red # color of the percentage
123
+ pad_symbols: "アイウエオ..." # characters picked at random to pad the column ("rain")
124
+ pad_color: green
125
+
126
+ # The symbol printed after every example.
127
+ example_status:
128
+ format: "{symbol}" # placeholders: {symbol}, {process_letter} (A for process 1, B for 2, ...)
129
+ symbols: # one random character of the string is picked each time
130
+ passed: "アイウエオ..."
131
+ failed: "アイウエオ..."
132
+ pending: "🥄"
133
+ colors:
134
+ passed: green
135
+ failed: red
136
+ pending: yellow
137
+ ```
138
+
139
+ Colors can be any name known to RSpec's console codes: `black`, `red`, `green`, `yellow`, `blue`, `magenta`,
140
+ `cyan`, `white` and their `bold_*` variants. Colors are always emitted, because CI logs render ANSI codes; set the
141
+ `NO_COLOR` environment variable to turn them off.
142
+
143
+ Example override: emoji for the examples, and a progress line whenever a process advances by 10 percent
144
+ (instead of once a minute):
145
+
146
+ ```yaml
147
+ progress_update:
148
+ interval_seconds: 0
149
+ percent_threshold: 10
150
+ example_status:
151
+ symbols:
152
+ passed: "🟢"
153
+ failed: "🔴"
154
+ pending: "🟡"
155
+ ```
156
+
157
+ ## Output suppression
158
+
159
+ With `suppress_output: true` (the default) the formatter reopens the STDOUT and STDERR file descriptors of every
160
+ test process to `/dev/null`, and keeps a private copy of the original stdout for the display. Application logs,
161
+ deprecation warnings, output of C extensions and child processes therefore cannot corrupt the display.
162
+
163
+ - RSpec creates formatters only after the files given with `--require` (for example `rails_helper`) are loaded, so
164
+ output printed while the application boots is not covered. To silence that too, load the gem's silence file
165
+ first:
166
+
167
+ ```sh
168
+ RUBYOPT="-rparallel_matrix_formatter/silence" bundle exec parallel_split_test --format ParallelMatrixFormatter::Formatter spec
169
+ ```
170
+
171
+ - Suppression also hides crashes. When a process dies without reporting, the summary shows a yellow warning
172
+ naming it. To see why, set `suppress_output: false`.
173
+ - If you pass `--out FILE` to RSpec, the display is written to that file instead of the terminal.
174
+
175
+ ## How it works
176
+
177
+ Every test process loads the formatter. Process 1 (`TEST_ENV_NUMBER` empty or `1`) additionally hosts the
178
+ orchestrator, which listens on a UNIX socket in the temporary directory (one per run). The other processes
179
+ connect to it and send the result of every example and, at the end, a summary of their run. The orchestrator
180
+ renders the progress lines and status symbols as messages arrive. When every process has sent its summary or has
181
+ disconnected, it prints the consolidated summary.
182
+
183
+ ## Development
184
+
185
+ ```sh
186
+ bundle install
187
+ bundle exec rake # runs the specs and RuboCop
188
+ bundle exec rspec # runs the specs in one process with RSpec's own formatter
189
+ ruby demo/matrix_demo.rb # previews the display without a test suite
190
+ ```
191
+
192
+ The specs eat their own dog food: `rake` (and CI) runs them with `parallel_split_test` and this formatter. CI
193
+ adds [`.github/parallel_matrix_formatter.yml`](.github/parallel_matrix_formatter.yml), which prints a progress line
194
+ whenever a process advances by 10 percent; to see the same locally:
195
+ `PARALLEL_MATRIX_FORMATTER_CONFIG=.github/parallel_matrix_formatter.yml bundle exec rake`.
196
+
197
+ ## Contributing
198
+
199
+ Bug reports and pull requests are welcome at https://github.com/vovka/parallel_matrix_formatter. Please add specs
200
+ for your changes and make sure `bundle exec rake` passes.
201
+
202
+ ## License
203
+
204
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,47 @@
1
+ # Default configuration of parallel_matrix_formatter.
2
+ #
3
+ # Copy this file to config/parallel_matrix_formatter.yml (or
4
+ # parallel_matrix_formatter.yml) in your project and change what you like.
5
+ # Keys you leave out fall back to the values below.
6
+
7
+ # Redirect stdout and stderr of every test process to /dev/null so application
8
+ # output cannot corrupt the display. This hides crashes too: set it to false
9
+ # when something goes wrong and you need to see why.
10
+ suppress_output: true
11
+
12
+ # Ten characters that replace the digits 0-9 in the time and the percentages,
13
+ # e.g. "ロイクヨムラレヌメワ". Leave empty to keep plain digits.
14
+ digits: ""
15
+
16
+ # When to print a fresh progress line. The first rule that applies wins.
17
+ progress_update:
18
+ always: false # after every example
19
+ interval_seconds: 60 # at most once per interval, plus once when every process is done; 0 disables
20
+ percent_threshold: 0 # whenever any process advances by this many percent; 0 disables
21
+
22
+ # The line showing every process's progress, e.g. "17:04:13 キコシ89%レミエ、キヲサ92%クェィケ".
23
+ # Placeholders: {time}, {columns}.
24
+ progress_line:
25
+ format: "\n{time} {columns} "
26
+ # One column per process: its percentage surrounded by random "rain" symbols.
27
+ column:
28
+ width: 10
29
+ align: center # left, center or right
30
+ color: red
31
+ pad_symbols: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワンヲァィゥェォャュョッー「」、"
32
+ pad_color: green
33
+
34
+ # The symbol printed after every example.
35
+ # Placeholders: {symbol}, {process_letter} (A for process 1, B for process 2, ...).
36
+ example_status:
37
+ format: "{symbol}"
38
+ # One random character of the string is picked each time.
39
+ symbols:
40
+ passed: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワンヲァィゥェォャュョッー「」、"
41
+ failed: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワンヲァィゥェォャュョッー「」、"
42
+ pending: "🥄"
43
+ # Any color RSpec knows: black, red, green, yellow, blue, magenta, cyan, white, bold_red, ...
44
+ colors:
45
+ passed: green
46
+ failed: red
47
+ pending: yellow
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module ParallelMatrixFormatter
6
+ # Loads the configuration: the gem's defaults deep-merged with the project's
7
+ # own file, so a project only has to spell out the keys it changes.
8
+ module Config
9
+ DEFAULTS_PATH = File.expand_path('../../config/parallel_matrix_formatter.yml', __dir__)
10
+ PROJECT_PATHS = ['parallel_matrix_formatter.yml', 'config/parallel_matrix_formatter.yml'].freeze
11
+
12
+ module_function
13
+
14
+ def load(path = project_path)
15
+ defaults = read(DEFAULTS_PATH)
16
+ path ? deep_merge(defaults, read(path)) : defaults
17
+ end
18
+
19
+ def project_path
20
+ ENV['PARALLEL_MATRIX_FORMATTER_CONFIG'] || PROJECT_PATHS.find { |path| File.exist?(path) }
21
+ end
22
+
23
+ def read(path)
24
+ YAML.safe_load_file(path) || {}
25
+ end
26
+
27
+ def deep_merge(base, overrides)
28
+ base.merge(overrides) do |_key, old_value, new_value|
29
+ old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/base_formatter'
4
+
5
+ module ParallelMatrixFormatter
6
+ # The RSpec formatter loaded into every test process. It silences the
7
+ # process, turns RSpec notifications into IPC messages for the orchestrator
8
+ # and, in process 1, hosts the orchestrator that renders the shared display.
9
+ class Formatter < RSpec::Core::Formatters::BaseFormatter
10
+ RSpec::Core::Formatters.register self, :start, :example_started, :example_passed,
11
+ :example_failed, :example_pending, :dump_summary, :close
12
+
13
+ def initialize(output)
14
+ config = Config.load
15
+ super(display_output(config, output))
16
+ @process_number = [ENV['TEST_ENV_NUMBER'].to_i, 1].max
17
+ @orchestrator = Orchestrator.for(@process_number, total_processes, self.output, config)
18
+ @examples_run = 0
19
+ @failures = []
20
+ end
21
+
22
+ def start(notification)
23
+ @total_examples = notification.count
24
+ @client = Ipc::Client.connect
25
+ end
26
+
27
+ def example_started(_notification)
28
+ @examples_run += 1
29
+ end
30
+
31
+ def example_passed(_notification)
32
+ report(:passed)
33
+ end
34
+
35
+ def example_pending(_notification)
36
+ report(:pending)
37
+ end
38
+
39
+ def example_failed(notification)
40
+ @failures << failure_details(notification)
41
+ report(:failed)
42
+ end
43
+
44
+ def dump_summary(summary)
45
+ @client.notify(type: 'summary', process: @process_number, duration: summary.duration,
46
+ examples: summary.example_count, failures: summary.failure_count,
47
+ pending: summary.pending_count, failed_examples: @failures)
48
+ end
49
+
50
+ def close(_notification)
51
+ @client&.close
52
+ @orchestrator.close
53
+ end
54
+
55
+ private
56
+
57
+ # Under parallel_split_test every process is forked by the same runner,
58
+ # which records the process count before forking.
59
+ def total_processes
60
+ (ParallelSplitTest.processes if defined?(ParallelSplitTest)) || 1
61
+ end
62
+
63
+ # The silenced terminal is where the display goes, unless RSpec was asked
64
+ # to write to a file with --out.
65
+ def display_output(config, output)
66
+ return output unless config['suppress_output']
67
+
68
+ terminal = Output::Silencer.silence
69
+ output.is_a?(File) ? output : terminal
70
+ end
71
+
72
+ def report(status)
73
+ progress = @examples_run.fdiv(@total_examples)
74
+ @client.notify(type: 'example', process: @process_number, status: status, progress: progress)
75
+ end
76
+
77
+ def failure_details(notification)
78
+ {
79
+ description: notification.description,
80
+ location: notification.example.location_rerun_argument,
81
+ message_lines: notification.colorized_message_lines(Rendering::Colors),
82
+ backtrace: notification.colorized_formatted_backtrace(Rendering::Colors)
83
+ }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+
6
+ module ParallelMatrixFormatter
7
+ module Ipc
8
+ # Sends messages to the orchestrator's server.
9
+ class Client
10
+ # Process 1 may still be loading spec files when the others start.
11
+ CONNECT_TIMEOUT = 120
12
+
13
+ def self.connect(path = Ipc.socket_path, timeout: CONNECT_TIMEOUT)
14
+ deadline = Time.now + timeout
15
+ begin
16
+ new(UNIXSocket.new(path))
17
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
18
+ raise Error, "no orchestrator listening at #{path} after #{timeout}s" if Time.now > deadline
19
+
20
+ sleep 0.1
21
+ retry
22
+ end
23
+ end
24
+
25
+ def initialize(socket)
26
+ @socket = socket
27
+ end
28
+
29
+ def notify(message)
30
+ @socket.puts(JSON.generate(message))
31
+ end
32
+
33
+ def close
34
+ @socket.close
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'socket'
6
+
7
+ module ParallelMatrixFormatter
8
+ module Ipc
9
+ # Accepts connections from every test process and queues their messages.
10
+ # When a client disconnects, a synthetic `disconnected` message carrying its
11
+ # process number is queued, so a crashed process is noticed too.
12
+ class Server
13
+ def initialize(path = Ipc.socket_path)
14
+ FileUtils.rm_f(path)
15
+ @path = path
16
+ @socket = UNIXServer.new(path)
17
+ @messages = Queue.new
18
+ @acceptor = Thread.new { accept_clients }
19
+ end
20
+
21
+ # Yields messages in arrival order until the server is closed.
22
+ def each_message
23
+ while (message = @messages.pop)
24
+ yield message
25
+ end
26
+ end
27
+
28
+ def close
29
+ @socket.close
30
+ @messages.close
31
+ FileUtils.rm_f(@path)
32
+ end
33
+
34
+ private
35
+
36
+ def accept_clients
37
+ loop { Thread.new(@socket.accept) { |client| read(client) } }
38
+ rescue IOError, Errno::EBADF
39
+ nil # the socket was closed
40
+ end
41
+
42
+ def read(client)
43
+ process = nil
44
+ while (line = client.gets)
45
+ message = JSON.parse(line)
46
+ process = message['process']
47
+ enqueue(message)
48
+ end
49
+ enqueue('type' => 'disconnected', 'process' => process) if process
50
+ ensure
51
+ client.close
52
+ end
53
+
54
+ def enqueue(message)
55
+ @messages << message
56
+ rescue ClosedQueueError
57
+ nil # nobody is listening any more
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module ParallelMatrixFormatter
6
+ # Communication between the test processes and the orchestrator: newline
7
+ # separated JSON messages over a UNIX socket.
8
+ module Ipc
9
+ # Every process started by parallel_split_test is forked by the same
10
+ # runner, so its pid identifies the run.
11
+ def self.socket_path
12
+ File.join(Dir.tmpdir, "parallel_matrix_formatter-#{Process.ppid}.sock")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ # Stands in for the Orchestrator in every process but process 1, which only
5
+ # report to the orchestrator and never render anything themselves.
6
+ class NullOrchestrator
7
+ def close; end
8
+ end
9
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ # Runs in process 1 only. Receives the messages of every test process over
5
+ # IPC, renders them as they arrive and prints the consolidated summary once
6
+ # every process has reported its summary or disconnected.
7
+ class Orchestrator
8
+ def self.for(process_number, total_processes, output, config)
9
+ return NullOrchestrator.new unless process_number == 1
10
+
11
+ new(total_processes, output, Rendering::Display.new(config, total_processes))
12
+ end
13
+
14
+ def initialize(total_processes, output, display)
15
+ @total_processes = total_processes
16
+ @output = output
17
+ @display = display
18
+ @summaries = {}
19
+ @disconnected = []
20
+ @server = Ipc::Server.new
21
+ @collector = Thread.new { collect_messages }
22
+ end
23
+
24
+ # Blocks until every process is done, then prints the summary.
25
+ def close
26
+ @collector.join
27
+ @output.puts @display.summary(@summaries.values, missing_processes)
28
+ @output.flush
29
+ @server.close
30
+ end
31
+
32
+ private
33
+
34
+ def collect_messages
35
+ @server.each_message do |message|
36
+ handle(message)
37
+ break if all_finished?
38
+ end
39
+ end
40
+
41
+ def handle(message)
42
+ case message['type']
43
+ when 'example' then print_example(message)
44
+ when 'summary' then @summaries[message['process']] = message
45
+ when 'disconnected' then @disconnected << message['process']
46
+ end
47
+ end
48
+
49
+ def print_example(message)
50
+ @output.print @display.example(message['process'], message['status'], message['progress'])
51
+ @output.flush
52
+ end
53
+
54
+ def all_finished?
55
+ missing_processes.all? { |process| @disconnected.include?(process) }
56
+ end
57
+
58
+ def missing_processes
59
+ (1..@total_processes).reject { |process| @summaries.key?(process) }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Output
5
+ # Redirects the process's stdout and stderr to /dev/null and keeps a private
6
+ # copy of the original stdout for the display. The redirection happens at
7
+ # the file descriptor level, so output written through loggers holding
8
+ # STDOUT, C extensions or child processes is silenced as well.
9
+ module Silencer
10
+ class << self
11
+ # @return [IO] the original stdout
12
+ def silence
13
+ @terminal = redirect_to_null unless silenced?
14
+ @terminal
15
+ end
16
+
17
+ def silenced?
18
+ !@terminal.nil?
19
+ end
20
+
21
+ private
22
+
23
+ def redirect_to_null
24
+ terminal = STDOUT.dup
25
+ terminal.sync = true
26
+ STDOUT.reopen(File::NULL, 'w')
27
+ STDERR.reopen(File::NULL, 'w')
28
+ terminal
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/console_codes'
4
+
5
+ module ParallelMatrixFormatter
6
+ module Rendering
7
+ # Wraps text in ANSI color codes. Accepts the color names RSpec knows
8
+ # (red, green, bold_blue, ...) as well as RSpec's semantic names (failure,
9
+ # detail, ...), so it doubles as the colorizer for RSpec's failure output.
10
+ # Colors are always on, since CI logs render them, unless NO_COLOR is set.
11
+ module Colors
12
+ module_function
13
+
14
+ def wrap(text, color)
15
+ return text.to_s if color.nil? || text.to_s.empty? || disabled?
16
+
17
+ code = RSpec::Core::Formatters::ConsoleCodes.console_code_for(color.to_sym)
18
+ "\e[#{code}m#{text}\e[0m"
19
+ end
20
+
21
+ def disabled?
22
+ !ENV['NO_COLOR'].to_s.empty?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Replaces the digits 0-9 of a string with custom characters, e.g. katakana.
6
+ module Digits
7
+ module_function
8
+
9
+ def replace(text, symbols)
10
+ return text if symbols.nil? || symbols.empty?
11
+
12
+ replacements = symbols.chars
13
+ text.gsub(/\d/) { |digit| replacements[digit.to_i] || digit }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Turns the orchestrator's events into terminal text. Remembers the latest
6
+ # progress of every process, starting at 0% for processes that have not
7
+ # reported yet, so every progress line shows one column per process.
8
+ class Display
9
+ def initialize(config, total_processes)
10
+ @progress = (1..total_processes).to_h { |process| [process, 0.0] }
11
+ @policy = ProgressUpdatePolicy.new(config['progress_update'], total_processes)
12
+ @progress_line = ProgressLine.new(config['progress_line'], config['digits'])
13
+ @example_status = ExampleStatus.new(config['example_status'])
14
+ @summary = Summary.new
15
+ end
16
+
17
+ # @return [String] the example's status symbol, preceded by a progress line when one is due
18
+ def example(process, status, progress)
19
+ @progress[process] = progress
20
+ line = @policy.update?(@progress) ? @progress_line.render(@progress) : ''
21
+ line + @example_status.render(process, status)
22
+ end
23
+
24
+ def summary(summaries, missing_processes)
25
+ @summary.render(summaries, missing_processes)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Renders the symbol printed for one finished example: a random character
6
+ # from the configured set for its status, in the status's color.
7
+ class ExampleStatus
8
+ def initialize(config)
9
+ @format = config['format']
10
+ @symbols = config['symbols']
11
+ @colors = config['colors']
12
+ end
13
+
14
+ # @param status [String] "passed", "failed" or "pending"
15
+ def render(process, status)
16
+ text = @format.gsub('{symbol}', symbol(status)).gsub('{process_letter}', process_letter(process))
17
+ Colors.wrap(text, @colors[status])
18
+ end
19
+
20
+ private
21
+
22
+ def symbol(status)
23
+ @symbols[status].to_s.chars.sample.to_s
24
+ end
25
+
26
+ def process_letter(process)
27
+ ('A'.ord + process - 1).chr
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Renders the percentage of one process padded to a fixed width with random
6
+ # "rain" symbols, e.g. "キコシ89%レミエ".
7
+ class ProgressColumn
8
+ def initialize(config, digits)
9
+ @width = config['width'].to_i
10
+ @align = config['align']
11
+ @color = config['color']
12
+ @pad_symbols = config['pad_symbols'].to_s.chars
13
+ @pad_symbols = [' '] if @pad_symbols.empty?
14
+ @pad_color = config['pad_color']
15
+ @digits = digits
16
+ end
17
+
18
+ def render(progress)
19
+ value = Digits.replace("#{(progress * 100).round}%", @digits)
20
+ left, right = padding_sizes([@width - value.length, 0].max)
21
+ Colors.wrap(rain(left), @pad_color) + Colors.wrap(value, @color) + Colors.wrap(rain(right), @pad_color)
22
+ end
23
+
24
+ private
25
+
26
+ def padding_sizes(total)
27
+ case @align
28
+ when 'left' then [0, total]
29
+ when 'right' then [total, 0]
30
+ else [total / 2, total - (total / 2)]
31
+ end
32
+ end
33
+
34
+ def rain(size)
35
+ Array.new(size) { @pad_symbols.sample }.join
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Renders the line showing the progress of every process, e.g.
6
+ # "\n17:04:13 キコシ89%レミエ、キヲサ92%クェィケ ".
7
+ class ProgressLine
8
+ def initialize(config, digits)
9
+ @format = config['format']
10
+ @digits = digits
11
+ @column = ProgressColumn.new(config['column'], digits)
12
+ end
13
+
14
+ # @param progress [Hash{Integer => Float}] latest progress of every process
15
+ def render(progress)
16
+ columns = progress.sort.map { |_process, value| @column.render(value) }.join
17
+ @format.gsub('{time}', time).gsub('{columns}', columns)
18
+ end
19
+
20
+ private
21
+
22
+ def time
23
+ Digits.replace(Time.now.strftime('%H:%M:%S'), @digits)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ module Rendering
5
+ # Decides whether a fresh progress line is due. The first configured rule
6
+ # wins: always, then the time interval, then the percentage threshold.
7
+ class ProgressUpdatePolicy
8
+ def initialize(config, total_processes)
9
+ @total_processes = total_processes
10
+ @always = config['always']
11
+ @interval = config['interval_seconds'].to_f
12
+ @threshold = config['percent_threshold'].to_f
13
+ @last_update_at = nil
14
+ @last_progress = {}
15
+ end
16
+
17
+ # @param progress [Hash{Integer => Float}] latest progress of every process
18
+ def update?(progress)
19
+ return true if @always
20
+ return interval_elapsed?(progress) if @interval.positive?
21
+ return threshold_crossed?(progress) if @threshold.positive?
22
+
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def interval_elapsed?(progress)
29
+ due = @last_update_at.nil? || Time.now - @last_update_at >= @interval || all_complete?(progress)
30
+ @last_update_at = Time.now if due
31
+ due
32
+ end
33
+
34
+ def threshold_crossed?(progress)
35
+ crossed = progress.select { |process, value| crossed?(@last_progress[process], value) }
36
+ @last_progress.merge!(crossed)
37
+ crossed.any?
38
+ end
39
+
40
+ def crossed?(previous, current)
41
+ return true if previous.nil?
42
+ return true if current >= 1.0 && previous < 1.0
43
+
44
+ (current - previous) * 100 >= @threshold
45
+ end
46
+
47
+ def all_complete?(progress)
48
+ progress.size == @total_processes && progress.values.all? { |value| value >= 1.0 }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/helpers'
4
+
5
+ module ParallelMatrixFormatter
6
+ module Rendering
7
+ # Renders the end-of-run report in the style of RSpec's own: every failure
8
+ # of every process, the totals and the commands to rerun the failures.
9
+ class Summary
10
+ Helpers = RSpec::Core::Formatters::Helpers
11
+
12
+ def initialize
13
+ @started_at = Time.now
14
+ end
15
+
16
+ # @param summaries [Array<Hash>] the summary message of every process that sent one
17
+ # @param missing_processes [Array<Integer>] processes that never sent a summary
18
+ def render(summaries, missing_processes)
19
+ failures = summaries.flat_map { |summary| summary['failed_examples'] }
20
+ sections = [failures_section(failures), warnings(missing_processes), totals(summaries), rerun_section(failures)]
21
+ "\n#{sections.compact.join("\n")}"
22
+ end
23
+
24
+ private
25
+
26
+ def failures_section(failures)
27
+ return if failures.empty?
28
+
29
+ blocks = failures.each_with_index.map { |failure, index| failure_block(failure, index + 1) }
30
+ "\nFailures:\n\n#{blocks.join("\n")}"
31
+ end
32
+
33
+ def failure_block(failure, number)
34
+ indent = ' ' * (number.to_s.length + 4)
35
+ details = failure['message_lines'] + failure['backtrace']
36
+ [" #{number}) #{failure['description']}", *details.map { |line| indent + line }, ''].join("\n")
37
+ end
38
+
39
+ def warnings(missing_processes)
40
+ return if missing_processes.empty?
41
+
42
+ processes = missing_processes.join(', ')
43
+ "\n#{Colors.wrap("WARNING: no summary received from process #{processes}, it probably crashed. " \
44
+ 'Set suppress_output: false to see its output.', :yellow)}"
45
+ end
46
+
47
+ def totals(summaries)
48
+ examples, failures, pending = %w[examples failures pending].map { |key| summaries.sum { |s| s[key] } }
49
+ process_time = Helpers.format_duration(summaries.sum { |summary| summary['duration'] })
50
+ "\nFinished in #{Helpers.format_duration(Time.now - @started_at)} (#{process_time} across processes)\n" +
51
+ Colors.wrap(totals_line(examples, failures, pending), totals_color(failures, pending))
52
+ end
53
+
54
+ def totals_line(examples, failures, pending)
55
+ line = "#{Helpers.pluralize(examples, 'example')}, #{Helpers.pluralize(failures, 'failure')}"
56
+ pending.positive? ? "#{line}, #{pending} pending" : line
57
+ end
58
+
59
+ def totals_color(failures, pending)
60
+ return :failure if failures.positive?
61
+
62
+ pending.positive? ? :pending : :success
63
+ end
64
+
65
+ def rerun_section(failures)
66
+ return if failures.empty?
67
+
68
+ "\nFailed examples:\n\n#{failures.map { |failure| rerun_command(failure) }.join("\n")}"
69
+ end
70
+
71
+ def rerun_command(failure)
72
+ command = Colors.wrap("rspec #{failure['location']}", :failure)
73
+ "#{command} #{Colors.wrap("# #{failure['description']}", :detail)}"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Silences the process as early as possible. RSpec only instantiates the
4
+ # formatter after the --require'd files have loaded, so output printed while
5
+ # the application boots would otherwise reach the terminal. Load this file
6
+ # before anything else to silence it:
7
+ #
8
+ # RUBYOPT="-rparallel_matrix_formatter/silence" bundle exec parallel_split_test ...
9
+ require_relative 'config'
10
+ require_relative 'output/silencer'
11
+
12
+ ParallelMatrixFormatter::Output::Silencer.silence if ParallelMatrixFormatter::Config.load['suppress_output']
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelMatrixFormatter
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+
5
+ require_relative 'parallel_matrix_formatter/version'
6
+ require_relative 'parallel_matrix_formatter/config'
7
+ require_relative 'parallel_matrix_formatter/output/silencer'
8
+ require_relative 'parallel_matrix_formatter/ipc'
9
+ require_relative 'parallel_matrix_formatter/ipc/client'
10
+ require_relative 'parallel_matrix_formatter/ipc/server'
11
+ require_relative 'parallel_matrix_formatter/rendering/colors'
12
+ require_relative 'parallel_matrix_formatter/rendering/digits'
13
+ require_relative 'parallel_matrix_formatter/rendering/progress_update_policy'
14
+ require_relative 'parallel_matrix_formatter/rendering/progress_column'
15
+ require_relative 'parallel_matrix_formatter/rendering/progress_line'
16
+ require_relative 'parallel_matrix_formatter/rendering/example_status'
17
+ require_relative 'parallel_matrix_formatter/rendering/summary'
18
+ require_relative 'parallel_matrix_formatter/rendering/display'
19
+ require_relative 'parallel_matrix_formatter/null_orchestrator'
20
+ require_relative 'parallel_matrix_formatter/orchestrator'
21
+ require_relative 'parallel_matrix_formatter/formatter'
22
+
23
+ # Matrix digital rain RSpec formatter for test suites split across processes
24
+ # with parallel_split_test.
25
+ #
26
+ # Every test process loads the Formatter, which silences the process and sends
27
+ # each example's result to process 1 over a UNIX socket. Process 1 also hosts
28
+ # the Orchestrator, which renders the shared display and, once every process has
29
+ # reported, the consolidated summary.
30
+ module ParallelMatrixFormatter
31
+ class Error < StandardError; end
32
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: parallel_matrix_formatter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Volodymyr Shcherbyna
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ description: An RSpec formatter that renders the progress of every parallel_split_test
27
+ process as one Matrix-style digital rain and prints a single consolidated summary.
28
+ email:
29
+ - scherbina.v@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - config/parallel_matrix_formatter.yml
38
+ - lib/parallel_matrix_formatter.rb
39
+ - lib/parallel_matrix_formatter/config.rb
40
+ - lib/parallel_matrix_formatter/formatter.rb
41
+ - lib/parallel_matrix_formatter/ipc.rb
42
+ - lib/parallel_matrix_formatter/ipc/client.rb
43
+ - lib/parallel_matrix_formatter/ipc/server.rb
44
+ - lib/parallel_matrix_formatter/null_orchestrator.rb
45
+ - lib/parallel_matrix_formatter/orchestrator.rb
46
+ - lib/parallel_matrix_formatter/output/silencer.rb
47
+ - lib/parallel_matrix_formatter/rendering/colors.rb
48
+ - lib/parallel_matrix_formatter/rendering/digits.rb
49
+ - lib/parallel_matrix_formatter/rendering/display.rb
50
+ - lib/parallel_matrix_formatter/rendering/example_status.rb
51
+ - lib/parallel_matrix_formatter/rendering/progress_column.rb
52
+ - lib/parallel_matrix_formatter/rendering/progress_line.rb
53
+ - lib/parallel_matrix_formatter/rendering/progress_update_policy.rb
54
+ - lib/parallel_matrix_formatter/rendering/summary.rb
55
+ - lib/parallel_matrix_formatter/silence.rb
56
+ - lib/parallel_matrix_formatter/version.rb
57
+ homepage: https://github.com/vovka/parallel_matrix_formatter
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/vovka/parallel_matrix_formatter
62
+ source_code_uri: https://github.com/vovka/parallel_matrix_formatter
63
+ changelog_uri: https://github.com/vovka/parallel_matrix_formatter/blob/main/CHANGELOG.md
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.2.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.6.9
80
+ specification_version: 4
81
+ summary: Matrix digital rain RSpec formatter for parallel_split_test
82
+ test_files: []