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 +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/config/parallel_matrix_formatter.yml +47 -0
- data/lib/parallel_matrix_formatter/config.rb +33 -0
- data/lib/parallel_matrix_formatter/formatter.rb +86 -0
- data/lib/parallel_matrix_formatter/ipc/client.rb +38 -0
- data/lib/parallel_matrix_formatter/ipc/server.rb +61 -0
- data/lib/parallel_matrix_formatter/ipc.rb +15 -0
- data/lib/parallel_matrix_formatter/null_orchestrator.rb +9 -0
- data/lib/parallel_matrix_formatter/orchestrator.rb +62 -0
- data/lib/parallel_matrix_formatter/output/silencer.rb +33 -0
- data/lib/parallel_matrix_formatter/rendering/colors.rb +26 -0
- data/lib/parallel_matrix_formatter/rendering/digits.rb +17 -0
- data/lib/parallel_matrix_formatter/rendering/display.rb +29 -0
- data/lib/parallel_matrix_formatter/rendering/example_status.rb +31 -0
- data/lib/parallel_matrix_formatter/rendering/progress_column.rb +39 -0
- data/lib/parallel_matrix_formatter/rendering/progress_line.rb +27 -0
- data/lib/parallel_matrix_formatter/rendering/progress_update_policy.rb +52 -0
- data/lib/parallel_matrix_formatter/rendering/summary.rb +77 -0
- data/lib/parallel_matrix_formatter/silence.rb +12 -0
- data/lib/parallel_matrix_formatter/version.rb +5 -0
- data/lib/parallel_matrix_formatter.rb +32 -0
- metadata +82 -0
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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,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: []
|