parallel_specs 0.9.0 → 0.9.1
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 +4 -4
- data/README.md +89 -3
- data/lib/parallel_specs/cli/dashboard.rb +92 -112
- data/lib/parallel_specs/cli.rb +140 -57
- data/lib/parallel_specs/grouper.rb +2 -2
- data/lib/parallel_specs/pids.rb +1 -1
- data/lib/parallel_specs/railtie.rb +11 -0
- data/lib/parallel_specs/rspec/dashboard_logger.rb +12 -12
- data/lib/parallel_specs/rspec/logger_base.rb +6 -6
- data/lib/parallel_specs/rspec/runner.rb +16 -16
- data/lib/parallel_specs/rspec/runtime_logger.rb +18 -9
- data/lib/parallel_specs/tasks.rb +264 -0
- data/lib/parallel_specs/test/runner.rb +31 -25
- data/lib/parallel_specs/version.rb +1 -1
- data/lib/parallel_specs.rb +30 -23
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7220b40d98f5c203c37cc69d12e9145e909dd4877d35e77fb0ae011f23ab3d3e
|
|
4
|
+
data.tar.gz: 40eadee9e0eb4748c3c17ad8c4b0bf016328c4564d655e014c8a0e22fbc5ae81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83ae2b21bd87c2445be0a1af045df3a2e0563a84eded7c62d08cb47792f7bc73d55f6416dea0aaa37b3ae49ebac3876804eb391c5219ff4c521e88074188aed5
|
|
7
|
+
data.tar.gz: 12bef5dbb566c689b5721f5a88339a69c3936eae64f1251977b4e1c847de2ab41c5e518d911ea31a4405321bbf9a2fabc6dd35d5942cc44ce35c7ec0e0015d76
|
data/README.md
CHANGED
|
@@ -9,16 +9,47 @@ A focused extraction of the RSpec pieces from `parallel_tests` with only the par
|
|
|
9
9
|
- runtime-log generation for balanced spec splitting
|
|
10
10
|
- modern Ruby only
|
|
11
11
|
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Add the gem to your app's Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
group :test do
|
|
18
|
+
gem 'parallel_specs'
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then install it:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For Rails apps, configure the test database name to include `TEST_ENV_NUMBER`. The first worker uses a blank value, then workers 2, 3, and so on use their worker number:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# config/database.yml
|
|
32
|
+
test:
|
|
33
|
+
database: my_app_test<%= ENV['TEST_ENV_NUMBER'] %>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you previously used `parallel_tests`, this is the same database naming convention. Once the `parallel_specs` Rake tasks are available, you can remove `parallel_tests` if you were only keeping it for database setup.
|
|
37
|
+
|
|
12
38
|
## Commands
|
|
13
39
|
|
|
14
40
|
```bash
|
|
15
41
|
bundle exec parallel_specs
|
|
16
42
|
bundle exec parallel_specs -n 6
|
|
43
|
+
bundle exec parallel_specs --dashboard-mode plain
|
|
44
|
+
bundle exec parallel_specs --plain
|
|
45
|
+
bundle exec parallel_specs --plain-dashboard
|
|
17
46
|
bundle exec parallel_specs --test-options='--tag ~type:system'
|
|
18
47
|
bundle exec parallel_specs --record-runtime
|
|
19
48
|
```
|
|
20
49
|
|
|
21
|
-
Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary.
|
|
50
|
+
Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary. Use `--plain`, `--dashboard-mode plain`, or `--plain-dashboard` to force plain dashboard output without setting an environment variable.
|
|
51
|
+
|
|
52
|
+
Plain dashboard output is intentionally minimal: it prints the examples counter and elapsed time, then relies on the process exit status and the final RSpec summary for success or failure. It does not print per-worker rows or current example names.
|
|
22
53
|
|
|
23
54
|
## Runtime balancing
|
|
24
55
|
|
|
@@ -40,15 +71,70 @@ bundle exec parallel_specs --record-runtime --runtime-log tmp/my_runtime.log
|
|
|
40
71
|
|
|
41
72
|
`--runtime-log PATH` is used as the input path for balancing and, with `--record-runtime`, as the output destination for the completed run.
|
|
42
73
|
|
|
74
|
+
## Rails database tasks
|
|
75
|
+
|
|
76
|
+
Rails apps that use `TEST_ENV_NUMBER` in `config/database.yml` can prepare per-worker test databases without depending on `parallel_tests`.
|
|
77
|
+
|
|
78
|
+
The most common workflow is:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Create all worker databases once
|
|
82
|
+
bundle exec rake parallel:create
|
|
83
|
+
|
|
84
|
+
# Load the current schema into each worker database
|
|
85
|
+
bundle exec rake parallel:load_schema
|
|
86
|
+
|
|
87
|
+
# Run the specs
|
|
88
|
+
bundle exec parallel_specs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
After changing migrations, refresh the worker databases with:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bundle exec rake parallel:prepare
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`parallel:prepare` checks for pending migrations, dumps the schema or structure once, and then loads it into each worker database.
|
|
98
|
+
|
|
99
|
+
To reset everything from scratch:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bundle exec rake parallel:drop
|
|
103
|
+
bundle exec rake parallel:create
|
|
104
|
+
bundle exec rake parallel:load_schema
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Each task accepts an optional worker count:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bundle exec rake 'parallel:create[4]'
|
|
111
|
+
bundle exec rake 'parallel:prepare[4]'
|
|
112
|
+
bundle exec parallel_specs -n 4
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If no count is provided, the tasks use `PARALLEL_SPECS_PROCESSORS` or the detected processor count. Use the same count for database prep and spec runs.
|
|
116
|
+
|
|
117
|
+
The gem also exposes compatible `parallel:setup`, `parallel:migrate`, `parallel:rollback`, `parallel:load_structure`, `parallel:seed`, and `parallel:rake` tasks.
|
|
118
|
+
|
|
119
|
+
Set `PARALLEL_SPECS_RAILS_ENV` to prepare an environment other than `test`.
|
|
120
|
+
|
|
43
121
|
## Environment variables
|
|
44
122
|
|
|
45
123
|
The supported environment variables are:
|
|
46
124
|
|
|
47
|
-
- `PARALLEL_SPECS_PROCESSORS` sets the default worker count when `-n` is not provided.
|
|
48
|
-
- `PARALLEL_SPECS_DASHBOARD_MODE` can be `interactive` or `plain` to override automatic dashboard selection.
|
|
125
|
+
- `PARALLEL_SPECS_PROCESSORS` sets the default worker count when `-n` is not provided and when Rails database tasks are run without a count.
|
|
126
|
+
- `PARALLEL_SPECS_DASHBOARD_MODE` can be `interactive` or `plain` to override automatic dashboard selection. You can also use `--plain`, `--dashboard-mode plain`, or `--plain-dashboard` for a single run.
|
|
49
127
|
- `PARALLEL_SPECS_HEARTBEAT_INTERVAL` sets the plain-dashboard heartbeat interval in seconds.
|
|
128
|
+
- `PARALLEL_SPECS_FULL_RERUN_COMMANDS=1` prints full failed-worker rerun commands even when they are long.
|
|
129
|
+
- `PARALLEL_SPECS_RERUN_COMMAND_SPEC_FILE_LIMIT` sets how many spec files a failed-worker rerun command may include before it is summarized instead of printed. The default is 25.
|
|
130
|
+
- `PARALLEL_SPECS_RERUN_COMMAND_CHAR_LIMIT` sets the maximum failed-worker rerun command length before it is summarized instead of printed. The default is 2000.
|
|
131
|
+
- `PARALLEL_SPECS_RAILS_ENV` sets the Rails environment used by the `parallel:*` database tasks. The default is `test`.
|
|
50
132
|
- `CI` makes dashboard output default to plain mode.
|
|
51
133
|
|
|
52
134
|
Worker processes continue to receive `TEST_ENV_NUMBER` for compatibility with existing test-environment isolation setup.
|
|
53
135
|
|
|
54
136
|
This gem does not intentionally preserve the old `parallel_tests` executable names, formatter paths, or environment variable aliases.
|
|
137
|
+
|
|
138
|
+
## Acknowledgements
|
|
139
|
+
|
|
140
|
+
This gem is largely based on the excellent work from [`parallel_tests`](https://github.com/grosser/parallel_tests), especially its approach to grouping test files, assigning `TEST_ENV_NUMBER`, and preparing per-worker Rails test databases.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require "json"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "strscan"
|
|
6
6
|
|
|
7
7
|
module ParallelSpecs
|
|
8
8
|
class CLI
|
|
@@ -20,7 +20,7 @@ module ParallelSpecs
|
|
|
20
20
|
:exit_status
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
-
SPINNER = [
|
|
23
|
+
SPINNER = ["-", "\\", "|", "/"].freeze
|
|
24
24
|
PROGRESS_BAR_WIDTH = 24
|
|
25
25
|
REFRESH_INTERVAL = 0.1
|
|
26
26
|
|
|
@@ -45,7 +45,8 @@ module ParallelSpecs
|
|
|
45
45
|
@event_offsets = Hash.new(0)
|
|
46
46
|
@event_remainders = Hash.new { |hash, key| hash[key] = +"" }
|
|
47
47
|
@spinner_index = 0
|
|
48
|
-
@
|
|
48
|
+
@rendered_rows = 0
|
|
49
|
+
@last_frame = nil
|
|
49
50
|
@dirty = true
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -70,7 +71,7 @@ module ParallelSpecs
|
|
|
70
71
|
render if @dirty
|
|
71
72
|
end
|
|
72
73
|
end
|
|
73
|
-
rescue
|
|
74
|
+
rescue => e
|
|
74
75
|
@running = false
|
|
75
76
|
warn "parallel_specs: dashboard refresh failed while polling #{event_file_context}: #{e.class}: #{e.message}"
|
|
76
77
|
end
|
|
@@ -84,7 +85,7 @@ module ParallelSpecs
|
|
|
84
85
|
@mutex.synchronize do
|
|
85
86
|
begin
|
|
86
87
|
poll_once
|
|
87
|
-
rescue
|
|
88
|
+
rescue => e
|
|
88
89
|
warn "parallel_specs: dashboard final poll failed while polling #{event_file_context}: #{e.class}: #{e.message}"
|
|
89
90
|
end
|
|
90
91
|
render
|
|
@@ -115,20 +116,20 @@ module ParallelSpecs
|
|
|
115
116
|
worker = @workers.fetch(process_number)
|
|
116
117
|
worker.started_at ||= @now.call
|
|
117
118
|
|
|
118
|
-
case event.fetch(
|
|
119
|
-
when
|
|
120
|
-
worker.example_total = event[
|
|
121
|
-
when
|
|
122
|
-
worker.current_example = event[
|
|
123
|
-
when
|
|
119
|
+
case event.fetch("event")
|
|
120
|
+
when "start"
|
|
121
|
+
worker.example_total = event["total"]
|
|
122
|
+
when "example_started"
|
|
123
|
+
worker.current_example = event["example"]
|
|
124
|
+
when "example_passed"
|
|
124
125
|
worker.passed += 1
|
|
125
|
-
worker.current_example = event[
|
|
126
|
-
when
|
|
126
|
+
worker.current_example = event["example"]
|
|
127
|
+
when "example_pending"
|
|
127
128
|
worker.pending += 1
|
|
128
|
-
worker.current_example = event[
|
|
129
|
-
when
|
|
129
|
+
worker.current_example = event["example"]
|
|
130
|
+
when "example_failed"
|
|
130
131
|
worker.failed += 1
|
|
131
|
-
worker.current_example = event[
|
|
132
|
+
worker.current_example = event["example"]
|
|
132
133
|
end
|
|
133
134
|
|
|
134
135
|
@dirty = true
|
|
@@ -138,7 +139,7 @@ module ParallelSpecs
|
|
|
138
139
|
@event_files.each do |process_number, path|
|
|
139
140
|
next unless File.exist?(path)
|
|
140
141
|
|
|
141
|
-
File.open(path,
|
|
142
|
+
File.open(path, "r") do |file|
|
|
142
143
|
file.seek(@event_offsets[process_number])
|
|
143
144
|
chunk = file.read.to_s
|
|
144
145
|
@event_offsets[process_number] = file.pos
|
|
@@ -151,7 +152,11 @@ module ParallelSpecs
|
|
|
151
152
|
lines.each do |line|
|
|
152
153
|
next if line.empty?
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
begin
|
|
156
|
+
process_event(process_number, JSON.parse(line))
|
|
157
|
+
rescue JSON::ParserError, KeyError => e
|
|
158
|
+
warn "parallel_specs: dashboard event ignored while polling worker #{process_number + 1}=#{path}: #{e.class}: #{e.message}"
|
|
159
|
+
end
|
|
155
160
|
end
|
|
156
161
|
end
|
|
157
162
|
end
|
|
@@ -161,7 +166,7 @@ module ParallelSpecs
|
|
|
161
166
|
lines = if interactive?
|
|
162
167
|
[header_line, *workers.map { |worker| worker_line(worker) }]
|
|
163
168
|
else
|
|
164
|
-
[plain_header_line
|
|
169
|
+
[plain_header_line]
|
|
165
170
|
end
|
|
166
171
|
"#{lines.join("\n")}\n"
|
|
167
172
|
end
|
|
@@ -173,7 +178,7 @@ module ParallelSpecs
|
|
|
173
178
|
end
|
|
174
179
|
|
|
175
180
|
def event_file_context
|
|
176
|
-
@event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(
|
|
181
|
+
@event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(", ")
|
|
177
182
|
end
|
|
178
183
|
|
|
179
184
|
def render
|
|
@@ -186,14 +191,20 @@ module ParallelSpecs
|
|
|
186
191
|
end
|
|
187
192
|
|
|
188
193
|
def render_interactive
|
|
194
|
+
@render_width = terminal_width
|
|
189
195
|
output = frame
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
rows_to_clear = [@rendered_rows, rendered_rows_for(@last_frame, @render_width)].max
|
|
197
|
+
|
|
198
|
+
if rows_to_clear.positive?
|
|
199
|
+
@output.print("\e[#{rows_to_clear}A")
|
|
192
200
|
@output.print("\r\e[J")
|
|
193
201
|
end
|
|
194
202
|
@output.print(output)
|
|
195
203
|
@output.flush if @output.respond_to?(:flush)
|
|
196
|
-
@
|
|
204
|
+
@rendered_rows = rendered_rows_for(output, @render_width)
|
|
205
|
+
@last_frame = output
|
|
206
|
+
ensure
|
|
207
|
+
@render_width = nil
|
|
197
208
|
end
|
|
198
209
|
|
|
199
210
|
def render_plain
|
|
@@ -202,29 +213,18 @@ module ParallelSpecs
|
|
|
202
213
|
end
|
|
203
214
|
|
|
204
215
|
def header_line
|
|
205
|
-
running = workers.count { |worker| status_for(worker) == :running }
|
|
206
|
-
passed = workers.count { |worker| status_for(worker) == :passed }
|
|
207
|
-
failed = workers.count { |worker| [:failing, :failed].include?(status_for(worker)) }
|
|
208
216
|
examples_seen = workers.sum { |worker| examples_seen_for(worker) }
|
|
209
217
|
total_examples = workers.filter_map(&:example_total)
|
|
210
218
|
example_summary = if total_examples.empty?
|
|
211
219
|
"examples: #{examples_seen}"
|
|
212
220
|
else
|
|
213
221
|
summary = "examples: #{examples_seen}/#{total_examples.sum}"
|
|
214
|
-
summary +=
|
|
222
|
+
summary += " known" unless total_examples.size == workers.size
|
|
215
223
|
summary
|
|
216
224
|
end
|
|
217
225
|
|
|
218
226
|
truncate(
|
|
219
|
-
[
|
|
220
|
-
'Parallel RSpec dashboard',
|
|
221
|
-
"workers: #{workers.size}",
|
|
222
|
-
"running: #{running}",
|
|
223
|
-
"passed: #{passed}",
|
|
224
|
-
"failed: #{failed}",
|
|
225
|
-
example_summary,
|
|
226
|
-
"elapsed: #{format_duration(elapsed_seconds)}"
|
|
227
|
-
].join(' | '),
|
|
227
|
+
[example_summary, "elapsed: #{format_duration(elapsed_seconds)}"].join(" | "),
|
|
228
228
|
terminal_width
|
|
229
229
|
)
|
|
230
230
|
end
|
|
@@ -242,65 +242,23 @@ module ParallelSpecs
|
|
|
242
242
|
failed: worker.failed,
|
|
243
243
|
pending: worker.pending
|
|
244
244
|
)
|
|
245
|
-
line += " | #{worker.current_example}" if worker.current_example
|
|
246
245
|
truncate(line, terminal_width)
|
|
247
246
|
end
|
|
248
247
|
|
|
249
248
|
def plain_header_line
|
|
250
|
-
|
|
251
|
-
passed = workers.count { |worker| status_for(worker) == :passed }
|
|
252
|
-
failed = workers.count { |worker| [:failing, :failed].include?(status_for(worker)) }
|
|
253
|
-
examples_seen = workers.sum { |worker| examples_seen_for(worker) }
|
|
254
|
-
total_examples = workers.filter_map(&:example_total)
|
|
255
|
-
|
|
256
|
-
parts = [
|
|
257
|
-
'dashboard',
|
|
258
|
-
"workers=#{workers.size}",
|
|
259
|
-
"running=#{running}",
|
|
260
|
-
"passed=#{passed}",
|
|
261
|
-
"failed=#{failed}",
|
|
262
|
-
"examples_seen=#{examples_seen}",
|
|
263
|
-
"elapsed=#{format_duration(elapsed_seconds)}"
|
|
264
|
-
]
|
|
265
|
-
|
|
266
|
-
unless total_examples.empty?
|
|
267
|
-
parts << "examples_total=#{total_examples.sum}"
|
|
268
|
-
parts << "examples_known=#{total_examples.size == workers.size}"
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
parts.join(' ')
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def plain_worker_line(worker)
|
|
275
|
-
parts = [
|
|
276
|
-
"worker=#{worker.label}",
|
|
277
|
-
"status=#{plain_status_text_for(worker)}",
|
|
278
|
-
"passed=#{worker.passed}",
|
|
279
|
-
"failed=#{worker.failed}",
|
|
280
|
-
"pending=#{worker.pending}",
|
|
281
|
-
"current_example=#{encode_plain_value(worker.current_example.to_s)}"
|
|
282
|
-
]
|
|
283
|
-
|
|
284
|
-
if worker.example_total
|
|
285
|
-
parts << "completed=#{examples_seen_for(worker)}"
|
|
286
|
-
parts << "total=#{worker.example_total}"
|
|
287
|
-
else
|
|
288
|
-
parts << "files=#{worker.files_count}"
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
parts.join(' ')
|
|
249
|
+
header_line
|
|
292
250
|
end
|
|
293
251
|
|
|
294
252
|
def progress_summary_for(worker)
|
|
295
253
|
if worker.example_total
|
|
296
|
-
format(
|
|
254
|
+
format("%<completed>d/%<total>d", completed: examples_seen_for(worker), total: worker.example_total)
|
|
297
255
|
else
|
|
298
|
-
format(
|
|
256
|
+
format("files:%<count>3d", count: worker.files_count)
|
|
299
257
|
end
|
|
300
258
|
end
|
|
301
259
|
|
|
302
260
|
def progress_bar_for(worker)
|
|
303
|
-
return "[#{
|
|
261
|
+
return "[#{"." * PROGRESS_BAR_WIDTH}]" unless worker.example_total
|
|
304
262
|
|
|
305
263
|
total = worker.example_total
|
|
306
264
|
completed = [examples_seen_for(worker), total].min
|
|
@@ -310,7 +268,7 @@ module ParallelSpecs
|
|
|
310
268
|
[(completed.to_f / total * PROGRESS_BAR_WIDTH).round, PROGRESS_BAR_WIDTH].min
|
|
311
269
|
end
|
|
312
270
|
empty = PROGRESS_BAR_WIDTH - filled
|
|
313
|
-
"[#{
|
|
271
|
+
"[#{"#" * filled}#{"-" * empty}]"
|
|
314
272
|
end
|
|
315
273
|
|
|
316
274
|
def examples_seen_for(worker)
|
|
@@ -335,21 +293,11 @@ module ParallelSpecs
|
|
|
335
293
|
|
|
336
294
|
def status_text_for(worker)
|
|
337
295
|
case status_for(worker)
|
|
338
|
-
when :queued then
|
|
296
|
+
when :queued then "· queued"
|
|
339
297
|
when :running then "#{SPINNER[@spinner_index % SPINNER.length]} running"
|
|
340
|
-
when :failing then
|
|
341
|
-
when :failed then
|
|
342
|
-
when :passed then
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def plain_status_text_for(worker)
|
|
347
|
-
case status_for(worker)
|
|
348
|
-
when :queued then 'queued'
|
|
349
|
-
when :running then 'running'
|
|
350
|
-
when :failing then 'failing'
|
|
351
|
-
when :failed then 'failed'
|
|
352
|
-
when :passed then 'passed'
|
|
298
|
+
when :failing then "! failing"
|
|
299
|
+
when :failed then "✗ failed"
|
|
300
|
+
when :passed then "✓ passed"
|
|
353
301
|
end
|
|
354
302
|
end
|
|
355
303
|
|
|
@@ -362,10 +310,6 @@ module ParallelSpecs
|
|
|
362
310
|
end
|
|
363
311
|
end
|
|
364
312
|
|
|
365
|
-
def encode_plain_value(value)
|
|
366
|
-
URI.encode_www_form_component(value)
|
|
367
|
-
end
|
|
368
|
-
|
|
369
313
|
def colorize(text, color)
|
|
370
314
|
return text unless @use_colors
|
|
371
315
|
|
|
@@ -388,27 +332,63 @@ module ParallelSpecs
|
|
|
388
332
|
remaining_seconds = seconds % 60
|
|
389
333
|
|
|
390
334
|
if hours > 0
|
|
391
|
-
format(
|
|
335
|
+
format("%<hours>d:%<minutes>02d:%<seconds>02d", hours: hours, minutes: minutes, seconds: remaining_seconds)
|
|
392
336
|
else
|
|
393
|
-
format(
|
|
337
|
+
format("%<minutes>02d:%<seconds>02d", minutes: minutes, seconds: remaining_seconds)
|
|
394
338
|
end
|
|
395
339
|
end
|
|
396
340
|
|
|
397
341
|
def terminal_width
|
|
398
|
-
@
|
|
342
|
+
return @render_width if @render_width
|
|
343
|
+
|
|
344
|
+
width = if @width.respond_to?(:call)
|
|
345
|
+
@width.call
|
|
346
|
+
elsif @width
|
|
347
|
+
@width
|
|
348
|
+
else
|
|
399
349
|
console = IO.console
|
|
400
350
|
console&.winsize&.last || 120
|
|
401
|
-
|
|
402
|
-
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
[width.to_i - 1, 1].max
|
|
354
|
+
rescue
|
|
355
|
+
119
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def rendered_rows_for(output, width)
|
|
359
|
+
return 0 if output.to_s.empty?
|
|
360
|
+
|
|
361
|
+
output.lines.sum do |line|
|
|
362
|
+
visible_width = visible_length(line.chomp)
|
|
363
|
+
[((visible_width - 1) / width) + 1, 1].max
|
|
403
364
|
end
|
|
404
365
|
end
|
|
405
366
|
|
|
406
367
|
def truncate(text, max_length)
|
|
407
|
-
return
|
|
408
|
-
return text if text
|
|
409
|
-
return
|
|
368
|
+
return "" if max_length <= 0
|
|
369
|
+
return text if visible_length(text) <= max_length
|
|
370
|
+
return "…" if max_length <= 1
|
|
371
|
+
|
|
372
|
+
truncated = +""
|
|
373
|
+
visible_count = 0
|
|
374
|
+
scanner = StringScanner.new(text)
|
|
375
|
+
|
|
376
|
+
until scanner.eos? || visible_count >= max_length - 1
|
|
377
|
+
if (escape_sequence = scanner.scan(/\e\[[\d;]*m/))
|
|
378
|
+
truncated << escape_sequence
|
|
379
|
+
else
|
|
380
|
+
truncated << scanner.getch
|
|
381
|
+
visible_count += 1
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
truncated << "…"
|
|
386
|
+
truncated << "\e[0m" if text.include?("\e[")
|
|
387
|
+
truncated
|
|
388
|
+
end
|
|
410
389
|
|
|
411
|
-
|
|
390
|
+
def visible_length(text)
|
|
391
|
+
text.gsub(/\e\[[\d;]*m/, "").length
|
|
412
392
|
end
|
|
413
393
|
end
|
|
414
394
|
end
|