parallel_specs 0.9.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/LICENSE +21 -0
- data/README.md +54 -0
- data/bin/parallel_specs +9 -0
- data/lib/parallel_specs/cli/dashboard.rb +415 -0
- data/lib/parallel_specs/cli.rb +381 -0
- data/lib/parallel_specs/grouper.rb +30 -0
- data/lib/parallel_specs/pids.rb +57 -0
- data/lib/parallel_specs/rspec/dashboard_logger.rb +67 -0
- data/lib/parallel_specs/rspec/logger_base.rb +46 -0
- data/lib/parallel_specs/rspec/runner.rb +122 -0
- data/lib/parallel_specs/rspec/runtime_logger.rb +46 -0
- data/lib/parallel_specs/test/runner.rb +242 -0
- data/lib/parallel_specs/version.rb +5 -0
- data/lib/parallel_specs.rb +94 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 31a954499f658d2d18df5d6126b522cf299985b33f68e5016d8a19de4e1369bd
|
|
4
|
+
data.tar.gz: 80789703549b936ae3b97309b0979a72d6799f10cd3ab604f11b7f3ed316509c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f994089098548075cccb2591e4e56dee64aee54fd861b92a30573a62e1358323b1bbf300af645f8aab23e6a5334c0f0b27cd834f61ffc118575f8c3e4737c83f
|
|
7
|
+
data.tar.gz: b6cf4496f6703d1d11d9375408bf718b7adf5c74327c8a9f12212b2956e17aabbb234f752092dbe44f4a97f5f9510351b32b5733f4f4646d8cd80ca6388bdbcb
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scott Watermasysk
|
|
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,54 @@
|
|
|
1
|
+
# Parallel Specs
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A focused extraction of the RSpec pieces from `parallel_tests` with only the parts this gem actually needs:
|
|
6
|
+
|
|
7
|
+
- a live local dashboard
|
|
8
|
+
- a plain-text CI / LLM friendly summary
|
|
9
|
+
- runtime-log generation for balanced spec splitting
|
|
10
|
+
- modern Ruby only
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle exec parallel_specs
|
|
16
|
+
bundle exec parallel_specs -n 6
|
|
17
|
+
bundle exec parallel_specs --test-options='--tag ~type:system'
|
|
18
|
+
bundle exec parallel_specs --record-runtime
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary.
|
|
22
|
+
|
|
23
|
+
## Runtime balancing
|
|
24
|
+
|
|
25
|
+
Regular runs automatically use `tmp/parallel_runtime_rspec.log` when it contains enough data.
|
|
26
|
+
|
|
27
|
+
Generate or refresh that file with:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle exec parallel_specs --record-runtime
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Runtime logs are replaced only after a successful, complete run where every worker produces its runtime log. Failed, interrupted, incomplete, or no-spec runs preserve the previous runtime log.
|
|
34
|
+
|
|
35
|
+
Or point at a custom file:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bundle exec parallel_specs --record-runtime --runtime-log tmp/my_runtime.log
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--runtime-log PATH` is used as the input path for balancing and, with `--record-runtime`, as the output destination for the completed run.
|
|
42
|
+
|
|
43
|
+
## Environment variables
|
|
44
|
+
|
|
45
|
+
The supported environment variables are:
|
|
46
|
+
|
|
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.
|
|
49
|
+
- `PARALLEL_SPECS_HEARTBEAT_INTERVAL` sets the plain-dashboard heartbeat interval in seconds.
|
|
50
|
+
- `CI` makes dashboard output default to plain mode.
|
|
51
|
+
|
|
52
|
+
Worker processes continue to receive `TEST_ENV_NUMBER` for compatibility with existing test-environment isolation setup.
|
|
53
|
+
|
|
54
|
+
This gem does not intentionally preserve the old `parallel_tests` executable names, formatter paths, or environment variable aliases.
|
data/bin/parallel_specs
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'io/console'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module ParallelSpecs
|
|
8
|
+
class CLI
|
|
9
|
+
class Dashboard
|
|
10
|
+
WorkerState = Struct.new(
|
|
11
|
+
:label,
|
|
12
|
+
:files_count,
|
|
13
|
+
:example_total,
|
|
14
|
+
:passed,
|
|
15
|
+
:failed,
|
|
16
|
+
:pending,
|
|
17
|
+
:current_example,
|
|
18
|
+
:started_at,
|
|
19
|
+
:finished_at,
|
|
20
|
+
:exit_status
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SPINNER = ['-', '\\', '|', '/'].freeze
|
|
24
|
+
PROGRESS_BAR_WIDTH = 24
|
|
25
|
+
REFRESH_INTERVAL = 0.1
|
|
26
|
+
|
|
27
|
+
attr_reader :workers
|
|
28
|
+
|
|
29
|
+
def plain?
|
|
30
|
+
@mode == :plain
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(groups:, event_files:, output: $stdout, use_colors: true, mode: :interactive, now: -> { ParallelSpecs.now }, width: nil, refresh_interval: REFRESH_INTERVAL)
|
|
34
|
+
@workers = groups.each_with_index.map do |group, index|
|
|
35
|
+
WorkerState.new(index + 1, group.size, nil, 0, 0, 0, nil, nil, nil, nil)
|
|
36
|
+
end
|
|
37
|
+
@event_files = event_files
|
|
38
|
+
@output = output
|
|
39
|
+
@use_colors = use_colors
|
|
40
|
+
@mode = mode
|
|
41
|
+
@now = now
|
|
42
|
+
@width = width
|
|
43
|
+
@refresh_interval = refresh_interval
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@event_offsets = Hash.new(0)
|
|
46
|
+
@event_remainders = Hash.new { |hash, key| hash[key] = +"" }
|
|
47
|
+
@spinner_index = 0
|
|
48
|
+
@rendered_lines = 0
|
|
49
|
+
@dirty = true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@started_at = @now.call
|
|
55
|
+
render if interactive?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return unless interactive?
|
|
59
|
+
|
|
60
|
+
@running = true
|
|
61
|
+
@refresh_thread = Thread.new do
|
|
62
|
+
Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
while @running
|
|
66
|
+
sleep @refresh_interval
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
poll_once
|
|
69
|
+
@spinner_index += 1
|
|
70
|
+
render if @dirty
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
@running = false
|
|
75
|
+
warn "parallel_specs: dashboard refresh failed while polling #{event_file_context}: #{e.class}: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stop
|
|
81
|
+
@running = false
|
|
82
|
+
@refresh_thread&.join
|
|
83
|
+
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
begin
|
|
86
|
+
poll_once
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn "parallel_specs: dashboard final poll failed while polling #{event_file_context}: #{e.class}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
render
|
|
91
|
+
@output.puts if interactive?
|
|
92
|
+
@output.flush if @output.respond_to?(:flush)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def worker_started(process_number)
|
|
97
|
+
synchronize do
|
|
98
|
+
worker = @workers.fetch(process_number)
|
|
99
|
+
worker.started_at ||= @now.call
|
|
100
|
+
@dirty = true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def worker_finished(process_number, exit_status:)
|
|
105
|
+
synchronize do
|
|
106
|
+
worker = @workers.fetch(process_number)
|
|
107
|
+
worker.started_at ||= @now.call
|
|
108
|
+
worker.finished_at = @now.call
|
|
109
|
+
worker.exit_status = exit_status
|
|
110
|
+
@dirty = true
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_event(process_number, event)
|
|
115
|
+
worker = @workers.fetch(process_number)
|
|
116
|
+
worker.started_at ||= @now.call
|
|
117
|
+
|
|
118
|
+
case event.fetch('event')
|
|
119
|
+
when 'start'
|
|
120
|
+
worker.example_total = event['total']
|
|
121
|
+
when 'example_started'
|
|
122
|
+
worker.current_example = event['example']
|
|
123
|
+
when 'example_passed'
|
|
124
|
+
worker.passed += 1
|
|
125
|
+
worker.current_example = event['example']
|
|
126
|
+
when 'example_pending'
|
|
127
|
+
worker.pending += 1
|
|
128
|
+
worker.current_example = event['example']
|
|
129
|
+
when 'example_failed'
|
|
130
|
+
worker.failed += 1
|
|
131
|
+
worker.current_example = event['example']
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@dirty = true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def poll_once
|
|
138
|
+
@event_files.each do |process_number, path|
|
|
139
|
+
next unless File.exist?(path)
|
|
140
|
+
|
|
141
|
+
File.open(path, 'r') do |file|
|
|
142
|
+
file.seek(@event_offsets[process_number])
|
|
143
|
+
chunk = file.read.to_s
|
|
144
|
+
@event_offsets[process_number] = file.pos
|
|
145
|
+
next if chunk.empty?
|
|
146
|
+
|
|
147
|
+
buffer = @event_remainders[process_number] << chunk
|
|
148
|
+
lines = buffer.split("\n", -1)
|
|
149
|
+
@event_remainders[process_number] = lines.pop.to_s
|
|
150
|
+
|
|
151
|
+
lines.each do |line|
|
|
152
|
+
next if line.empty?
|
|
153
|
+
|
|
154
|
+
process_event(process_number, JSON.parse(line))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def frame
|
|
161
|
+
lines = if interactive?
|
|
162
|
+
[header_line, *workers.map { |worker| worker_line(worker) }]
|
|
163
|
+
else
|
|
164
|
+
[plain_header_line, *workers.map { |worker| plain_worker_line(worker) }]
|
|
165
|
+
end
|
|
166
|
+
"#{lines.join("\n")}\n"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def synchronize(&block)
|
|
172
|
+
@mutex.synchronize(&block)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def event_file_context
|
|
176
|
+
@event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(', ')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def render
|
|
180
|
+
if interactive?
|
|
181
|
+
render_interactive
|
|
182
|
+
else
|
|
183
|
+
render_plain
|
|
184
|
+
end
|
|
185
|
+
@dirty = false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_interactive
|
|
189
|
+
output = frame
|
|
190
|
+
if @rendered_lines > 0
|
|
191
|
+
@output.print("\e[#{@rendered_lines}A")
|
|
192
|
+
@output.print("\r\e[J")
|
|
193
|
+
end
|
|
194
|
+
@output.print(output)
|
|
195
|
+
@output.flush if @output.respond_to?(:flush)
|
|
196
|
+
@rendered_lines = output.lines.count
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def render_plain
|
|
200
|
+
@output.print(frame)
|
|
201
|
+
@output.flush if @output.respond_to?(:flush)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
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
|
+
examples_seen = workers.sum { |worker| examples_seen_for(worker) }
|
|
209
|
+
total_examples = workers.filter_map(&:example_total)
|
|
210
|
+
example_summary = if total_examples.empty?
|
|
211
|
+
"examples: #{examples_seen}"
|
|
212
|
+
else
|
|
213
|
+
summary = "examples: #{examples_seen}/#{total_examples.sum}"
|
|
214
|
+
summary += ' known' unless total_examples.size == workers.size
|
|
215
|
+
summary
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
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(' | '),
|
|
228
|
+
terminal_width
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def worker_line(worker)
|
|
233
|
+
plain_status = status_text_for(worker)
|
|
234
|
+
colored_status = colorize(plain_status.ljust(9), status_color_for(worker))
|
|
235
|
+
line = format(
|
|
236
|
+
"[%<label>2d] %<status>s %<bar>s %<summary>9s p:%<passed>3d f:%<failed>3d pend:%<pending>3d",
|
|
237
|
+
label: worker.label,
|
|
238
|
+
status: colored_status,
|
|
239
|
+
bar: progress_bar_for(worker),
|
|
240
|
+
summary: progress_summary_for(worker),
|
|
241
|
+
passed: worker.passed,
|
|
242
|
+
failed: worker.failed,
|
|
243
|
+
pending: worker.pending
|
|
244
|
+
)
|
|
245
|
+
line += " | #{worker.current_example}" if worker.current_example
|
|
246
|
+
truncate(line, terminal_width)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def plain_header_line
|
|
250
|
+
running = workers.count { |worker| status_for(worker) == :running }
|
|
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(' ')
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def progress_summary_for(worker)
|
|
295
|
+
if worker.example_total
|
|
296
|
+
format('%<completed>d/%<total>d', completed: examples_seen_for(worker), total: worker.example_total)
|
|
297
|
+
else
|
|
298
|
+
format('files:%<count>3d', count: worker.files_count)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def progress_bar_for(worker)
|
|
303
|
+
return "[#{'.' * PROGRESS_BAR_WIDTH}]" unless worker.example_total
|
|
304
|
+
|
|
305
|
+
total = worker.example_total
|
|
306
|
+
completed = [examples_seen_for(worker), total].min
|
|
307
|
+
filled = if total == 0
|
|
308
|
+
PROGRESS_BAR_WIDTH
|
|
309
|
+
else
|
|
310
|
+
[(completed.to_f / total * PROGRESS_BAR_WIDTH).round, PROGRESS_BAR_WIDTH].min
|
|
311
|
+
end
|
|
312
|
+
empty = PROGRESS_BAR_WIDTH - filled
|
|
313
|
+
"[#{'#' * filled}#{'-' * empty}]"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def examples_seen_for(worker)
|
|
317
|
+
worker.passed + worker.failed + worker.pending
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def status_for(worker)
|
|
321
|
+
if worker.finished_at
|
|
322
|
+
if worker.failed > 0 || worker.exit_status.to_i != 0
|
|
323
|
+
:failed
|
|
324
|
+
else
|
|
325
|
+
:passed
|
|
326
|
+
end
|
|
327
|
+
elsif worker.failed > 0
|
|
328
|
+
:failing
|
|
329
|
+
elsif worker.started_at
|
|
330
|
+
:running
|
|
331
|
+
else
|
|
332
|
+
:queued
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def status_text_for(worker)
|
|
337
|
+
case status_for(worker)
|
|
338
|
+
when :queued then '· queued'
|
|
339
|
+
when :running then "#{SPINNER[@spinner_index % SPINNER.length]} running"
|
|
340
|
+
when :failing then '! failing'
|
|
341
|
+
when :failed then '✗ failed'
|
|
342
|
+
when :passed then '✓ passed'
|
|
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'
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def status_color_for(worker)
|
|
357
|
+
case status_for(worker)
|
|
358
|
+
when :queued then 90
|
|
359
|
+
when :running then 33
|
|
360
|
+
when :failing, :failed then 31
|
|
361
|
+
when :passed then 32
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def encode_plain_value(value)
|
|
366
|
+
URI.encode_www_form_component(value)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def colorize(text, color)
|
|
370
|
+
return text unless @use_colors
|
|
371
|
+
|
|
372
|
+
"\e[#{color}m#{text}\e[0m"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def interactive?
|
|
376
|
+
@mode == :interactive
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def elapsed_seconds
|
|
380
|
+
return 0 unless @started_at
|
|
381
|
+
|
|
382
|
+
(@now.call - @started_at).to_i
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def format_duration(seconds)
|
|
386
|
+
hours = seconds / 3600
|
|
387
|
+
minutes = (seconds % 3600) / 60
|
|
388
|
+
remaining_seconds = seconds % 60
|
|
389
|
+
|
|
390
|
+
if hours > 0
|
|
391
|
+
format('%<hours>d:%<minutes>02d:%<seconds>02d', hours: hours, minutes: minutes, seconds: remaining_seconds)
|
|
392
|
+
else
|
|
393
|
+
format('%<minutes>02d:%<seconds>02d', minutes: minutes, seconds: remaining_seconds)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def terminal_width
|
|
398
|
+
@terminal_width ||= @width || begin
|
|
399
|
+
console = IO.console
|
|
400
|
+
console&.winsize&.last || 120
|
|
401
|
+
rescue StandardError
|
|
402
|
+
120
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def truncate(text, max_length)
|
|
407
|
+
return '' if max_length <= 0
|
|
408
|
+
return text if text.length <= max_length
|
|
409
|
+
return text[0, max_length] if max_length <= 1
|
|
410
|
+
|
|
411
|
+
"#{text[0, max_length - 1]}…"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|