rspec_telemetry 0.3.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 +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/examples/sample.ndjson +15 -0
- data/exe/rspec-telemetry +7 -0
- data/exe/rspec-telemetry-compare +6 -0
- data/exe/rspec-telemetry-viewer +67 -0
- data/lib/rspec_telemetry/analyzer.rb +170 -0
- data/lib/rspec_telemetry/cli.rb +71 -0
- data/lib/rspec_telemetry/compare_cli.rb +129 -0
- data/lib/rspec_telemetry/config.rb +40 -0
- data/lib/rspec_telemetry/console_report.rb +124 -0
- data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
- data/lib/rspec_telemetry/factory_comparison.rb +101 -0
- data/lib/rspec_telemetry/formatter.rb +91 -0
- data/lib/rspec_telemetry/ndjson.rb +24 -0
- data/lib/rspec_telemetry/recorder.rb +75 -0
- data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
- data/lib/rspec_telemetry/summary.rb +134 -0
- data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
- data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
- data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
- data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
- data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
- data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
- data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
- data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
- data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
- data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
- data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
- data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
- data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
- data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
- data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
- data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
- data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
- data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
- data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
- data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
- data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
- data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
- data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
- data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
- data/lib/rspec_telemetry/trace/viewer.rb +31 -0
- data/lib/rspec_telemetry/version.rb +5 -0
- data/lib/rspec_telemetry/writer.rb +59 -0
- data/lib/rspec_telemetry.rb +102 -0
- metadata +122 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../document"
|
|
4
|
+
require_relative "../theme"
|
|
5
|
+
require_relative "../report_view"
|
|
6
|
+
require_relative "../report_pane"
|
|
7
|
+
require_relative "../detail_lines"
|
|
8
|
+
|
|
9
|
+
module RSpecTelemetry
|
|
10
|
+
module Trace
|
|
11
|
+
module Viewer
|
|
12
|
+
module Screen
|
|
13
|
+
class RankedScreen
|
|
14
|
+
def initialize(document, list, view)
|
|
15
|
+
@document = document
|
|
16
|
+
@list = list
|
|
17
|
+
@view = view
|
|
18
|
+
refresh
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def title = ReportView.title(@view)
|
|
22
|
+
def time_bar? = false
|
|
23
|
+
def time_bar_current = nil
|
|
24
|
+
def count = @rows.size
|
|
25
|
+
def source? = @view == :examples && @rows.any?(&:source)
|
|
26
|
+
|
|
27
|
+
def activate = refresh
|
|
28
|
+
|
|
29
|
+
def refresh
|
|
30
|
+
@rows = ReportView.rows(@document, @view)
|
|
31
|
+
@list.count = @rows.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def draw_list(canvas, rect, focused:)
|
|
35
|
+
ReportPane.new(@rows, list: @list, focus: focused).draw(canvas, rect)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def detail_lines(width)
|
|
39
|
+
row = @rows[@list.cursor]
|
|
40
|
+
return [] unless row
|
|
41
|
+
|
|
42
|
+
if row.payload.is_a?(Document::Action)
|
|
43
|
+
action = row.payload
|
|
44
|
+
DetailLines.for(
|
|
45
|
+
action,
|
|
46
|
+
children: @document.events_for(action.seq),
|
|
47
|
+
duration: action.duration_ms,
|
|
48
|
+
width: width
|
|
49
|
+
)
|
|
50
|
+
else
|
|
51
|
+
ReportView.detail(row.payload).flat_map do |line|
|
|
52
|
+
TuiTui::DisplayText.new(line).wrap(width, indent: " ").map(&:to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def current_source
|
|
58
|
+
@view == :examples ? @rows[@list.cursor]&.source : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_key_event(_event, _app) = nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "../document"
|
|
6
|
+
require_relative "../theme"
|
|
7
|
+
require_relative "../label"
|
|
8
|
+
require_relative "../timeline_pane"
|
|
9
|
+
require_relative "../detail_lines"
|
|
10
|
+
|
|
11
|
+
module RSpecTelemetry
|
|
12
|
+
module Trace
|
|
13
|
+
module Viewer
|
|
14
|
+
module Screen
|
|
15
|
+
# Timeline-only state: filtering, folding, failure jumps, and live refresh.
|
|
16
|
+
class TimelineScreen
|
|
17
|
+
def initialize(document, list)
|
|
18
|
+
@document = document
|
|
19
|
+
@list = list
|
|
20
|
+
@filter = nil
|
|
21
|
+
@collapsed = Set.new
|
|
22
|
+
rebuild
|
|
23
|
+
compute_durations
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def title = nil
|
|
27
|
+
def time_bar? = true
|
|
28
|
+
def source? = @document.actions.any?(&:source)
|
|
29
|
+
def count = @visible.size
|
|
30
|
+
|
|
31
|
+
def activate
|
|
32
|
+
@collapsed.clear
|
|
33
|
+
rebuild
|
|
34
|
+
compute_durations
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def refresh
|
|
38
|
+
rebuild
|
|
39
|
+
compute_durations
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def draw_list(canvas, rect, focused:)
|
|
43
|
+
TimelinePane
|
|
44
|
+
.new(
|
|
45
|
+
@visible,
|
|
46
|
+
list: @list,
|
|
47
|
+
focus: focused,
|
|
48
|
+
collapsed: @collapsed,
|
|
49
|
+
childful: @childful,
|
|
50
|
+
durations: @durations
|
|
51
|
+
)
|
|
52
|
+
.draw(canvas, rect)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def detail_lines(width)
|
|
56
|
+
entry = current_entry
|
|
57
|
+
children = entry.is_a?(Document::Action) ? @document.events_for(entry.seq) : []
|
|
58
|
+
duration = entry.is_a?(Document::Action) ? @durations[entry.seq] : nil
|
|
59
|
+
DetailLines.for(entry, children: children, duration: duration, width: width)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def current_source
|
|
63
|
+
entry = current_entry
|
|
64
|
+
return entry.source if entry.is_a?(Document::Action)
|
|
65
|
+
|
|
66
|
+
action = entry.is_a?(Document::Event) ? @document.action(entry.action) : nil
|
|
67
|
+
action&.source
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def time_bar_current
|
|
71
|
+
entry = current_entry
|
|
72
|
+
return nil if entry.nil?
|
|
73
|
+
|
|
74
|
+
entry.is_a?(Document::Action) ? entry.wall_ms : entry.fields["wall_ms"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_key_event(event, app)
|
|
78
|
+
case event.key
|
|
79
|
+
when "/"
|
|
80
|
+
open_filter(app)
|
|
81
|
+
when "a"
|
|
82
|
+
open_example_jump(app)
|
|
83
|
+
when "n"
|
|
84
|
+
jump_error(app, 1)
|
|
85
|
+
when "N"
|
|
86
|
+
jump_error(app, -1)
|
|
87
|
+
when "\r"
|
|
88
|
+
toggle_fold(app)
|
|
89
|
+
when "z"
|
|
90
|
+
toggle_all_folds(app)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def current_entry = @visible[@list.cursor]
|
|
97
|
+
|
|
98
|
+
def rebuild
|
|
99
|
+
base = filtered_entries
|
|
100
|
+
@childful = base.filter_map { |entry| entry.action if entry.is_a?(Document::Event) }.to_set
|
|
101
|
+
@visible = base.reject { |entry| entry.is_a?(Document::Event) && @collapsed.include?(entry.action) }
|
|
102
|
+
@list.count = @visible.size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def filtered_entries
|
|
106
|
+
return @document.entries unless @filter
|
|
107
|
+
|
|
108
|
+
needle = @filter.downcase
|
|
109
|
+
@document.entries.select { |entry| Label.plain(entry).downcase.include?(needle) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compute_durations
|
|
113
|
+
actions = @document.entries.grep(Document::Action)
|
|
114
|
+
@durations = {}
|
|
115
|
+
actions.each_with_index do |action, index|
|
|
116
|
+
# Running examples use the gap to the next example or stream end.
|
|
117
|
+
@durations[action.seq] = if action.duration_ms
|
|
118
|
+
action.duration_ms
|
|
119
|
+
else
|
|
120
|
+
finish = actions[index + 1]&.wall_ms || @document.end_wall_ms
|
|
121
|
+
finish && action.wall_ms ? (finish - action.wall_ms) : nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def open_filter(app)
|
|
127
|
+
app.open_modal(TuiTui::Prompt.new("Filter:", value: @filter.to_s, theme: Theme.base)) do |result|
|
|
128
|
+
apply_filter(app, result[1]) if result.is_a?(Array)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def apply_filter(app, text)
|
|
133
|
+
@filter = text.empty? ? nil : text
|
|
134
|
+
rebuild
|
|
135
|
+
app.go_to(0)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def open_example_jump(app)
|
|
139
|
+
rows = @visible.each_index.select { |i| @visible[i].is_a?(Document::Action) }
|
|
140
|
+
return if rows.empty?
|
|
141
|
+
|
|
142
|
+
app
|
|
143
|
+
.open_modal(
|
|
144
|
+
TuiTui::Select.new("Jump to example", rows.map { |i| Label.plain(@visible[i]) }, theme: Theme.base)
|
|
145
|
+
) do |result|
|
|
146
|
+
app.go_to(rows[result]) if result.is_a?(Integer)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def jump_error(app, direction)
|
|
151
|
+
index = @list.cursor + direction
|
|
152
|
+
while index.between?(0, @list.last)
|
|
153
|
+
entry = @visible[index]
|
|
154
|
+
return app.go_to(index) if entry.is_a?(Document::Action) && entry.failed?
|
|
155
|
+
|
|
156
|
+
index += direction
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def toggle_fold(app)
|
|
161
|
+
entry = current_entry
|
|
162
|
+
seq = entry.is_a?(Document::Action) ? entry.seq : entry&.action
|
|
163
|
+
return unless seq && @childful.include?(seq)
|
|
164
|
+
|
|
165
|
+
@collapsed.include?(seq) ? @collapsed.delete(seq) : @collapsed.add(seq)
|
|
166
|
+
rebuild
|
|
167
|
+
index = @visible.index { |e| e.is_a?(Document::Action) && e.seq == seq }
|
|
168
|
+
app.go_to(index) if index
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def toggle_all_folds(app)
|
|
172
|
+
@collapsed.empty? ? @collapsed.replace(@childful) : @collapsed.clear
|
|
173
|
+
rebuild
|
|
174
|
+
app.go_to(@list.cursor)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
module Trace
|
|
5
|
+
module Viewer
|
|
6
|
+
class TailSource
|
|
7
|
+
def initialize(path)
|
|
8
|
+
@path = path
|
|
9
|
+
@offset = 0
|
|
10
|
+
@partial = +""
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def drain
|
|
14
|
+
return [] unless File.file?(@path)
|
|
15
|
+
|
|
16
|
+
reset_if_shrunk
|
|
17
|
+
chunk = read_new_bytes
|
|
18
|
+
return [] if chunk.empty?
|
|
19
|
+
|
|
20
|
+
@partial << chunk
|
|
21
|
+
split_complete_lines
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def reset_if_shrunk
|
|
27
|
+
return unless File.size(@path) < @offset
|
|
28
|
+
|
|
29
|
+
@offset = 0
|
|
30
|
+
@partial = +""
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_new_bytes
|
|
34
|
+
return "" if File.size(@path) <= @offset
|
|
35
|
+
|
|
36
|
+
File.open(@path, "rb") do |file|
|
|
37
|
+
file.seek(@offset)
|
|
38
|
+
data = file.read || ""
|
|
39
|
+
@offset = file.pos
|
|
40
|
+
data
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def split_complete_lines
|
|
45
|
+
pieces = @partial.split("\n", -1)
|
|
46
|
+
@partial = pieces.pop || +""
|
|
47
|
+
pieces.map { |line| line.dup.force_encoding("UTF-8") }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
class SourcePane
|
|
9
|
+
HEADER = TuiTui::Style.new(attrs: [:reverse])
|
|
10
|
+
CODE = TuiTui::Style.new(attrs: [:dim])
|
|
11
|
+
MARK = TuiTui::Style.new(fg: :green, attrs: [:bold])
|
|
12
|
+
|
|
13
|
+
def initialize(location:, lines:, target:)
|
|
14
|
+
@location = location
|
|
15
|
+
@lines = lines
|
|
16
|
+
@target = target
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def draw(canvas, rect)
|
|
20
|
+
canvas.fill(TuiTui::Rect.new(row: rect.row, col: rect.col, rows: 1, cols: rect.cols), HEADER)
|
|
21
|
+
canvas.text(
|
|
22
|
+
rect.row,
|
|
23
|
+
rect.col,
|
|
24
|
+
TuiTui::DisplayText.new(" source: #{@location || "(none for this step)"}").truncate(rect.cols),
|
|
25
|
+
HEADER
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
avail = rect.rows - 1
|
|
29
|
+
return canvas if avail < 1
|
|
30
|
+
return placeholder(canvas, rect.row + 1, rect.col, rect.cols) if @lines.nil?
|
|
31
|
+
|
|
32
|
+
body = TuiTui::Rect.new(row: rect.row + 1, col: rect.col, rows: avail, cols: rect.cols)
|
|
33
|
+
draw_window(canvas, body)
|
|
34
|
+
canvas
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def placeholder(canvas, row, col, cols)
|
|
40
|
+
text = @location ? "(source not found)" : "(this step has no recorded source)"
|
|
41
|
+
canvas.text(row, col, TuiTui::DisplayText.new(" #{text}").truncate(cols), CODE)
|
|
42
|
+
canvas
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.numbered_line(number, text, target:)
|
|
46
|
+
"#{number == target ? "→" : " "} #{number.to_s.rjust(4)} #{text}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def draw_window(canvas, body)
|
|
50
|
+
top = window_top(body.rows)
|
|
51
|
+
TuiTui::TextView.draw(canvas, body, top: top) do |index|
|
|
52
|
+
text = @lines[index]
|
|
53
|
+
next nil if text.nil?
|
|
54
|
+
|
|
55
|
+
number = index + 1
|
|
56
|
+
line = self.class.numbered_line(number, text, target: @target)
|
|
57
|
+
TuiTui::Line[TuiTui::Span[line, number == @target ? MARK : CODE]]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def window_top(avail)
|
|
62
|
+
return 0 if @target.nil?
|
|
63
|
+
|
|
64
|
+
centered = @target - 1 - (avail / 2)
|
|
65
|
+
centered.clamp(0, [@lines.size - avail, 0].max)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
module Trace
|
|
5
|
+
module Viewer
|
|
6
|
+
class SourceResolver
|
|
7
|
+
ANCESTOR_DEPTH = 5
|
|
8
|
+
|
|
9
|
+
def initialize(source_root:, base_dir: nil)
|
|
10
|
+
@source_root = source_root
|
|
11
|
+
@base_dir = base_dir
|
|
12
|
+
@cache = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def lines_for(file)
|
|
16
|
+
return @cache[file] if @cache.key?(file)
|
|
17
|
+
|
|
18
|
+
@cache[file] = read(file)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def roots
|
|
22
|
+
@roots ||= begin
|
|
23
|
+
list = [@source_root]
|
|
24
|
+
dir = @base_dir
|
|
25
|
+
ANCESTOR_DEPTH.times do
|
|
26
|
+
break if dir.nil?
|
|
27
|
+
|
|
28
|
+
list << dir
|
|
29
|
+
parent = ::File.dirname(dir)
|
|
30
|
+
break if parent == dir
|
|
31
|
+
|
|
32
|
+
dir = parent
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
list.compact.uniq
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def read(file)
|
|
42
|
+
roots.each do |root|
|
|
43
|
+
path = ::File.expand_path(file, root)
|
|
44
|
+
next unless ::File.file?(path)
|
|
45
|
+
|
|
46
|
+
return ::File.readlines(path, chomp: true)
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tui_tui"
|
|
4
|
+
|
|
5
|
+
require_relative "source_pane"
|
|
6
|
+
require_relative "source_resolver"
|
|
7
|
+
require_relative "theme"
|
|
8
|
+
|
|
9
|
+
module RSpecTelemetry
|
|
10
|
+
module Trace
|
|
11
|
+
module Viewer
|
|
12
|
+
class SourceView
|
|
13
|
+
def initialize(source_root:, base_dir:)
|
|
14
|
+
@resolver = SourceResolver.new(source_root: source_root, base_dir: base_dir)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def draw(canvas, rect, location)
|
|
18
|
+
file, target = source_location(location)
|
|
19
|
+
SourcePane
|
|
20
|
+
.new(location: location, lines: file && @resolver.lines_for(file), target: target)
|
|
21
|
+
.draw(canvas, rect)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pager(location)
|
|
25
|
+
return nil unless location
|
|
26
|
+
|
|
27
|
+
file, target = source_location(location)
|
|
28
|
+
lines = numbered_source(@resolver.lines_for(file), target, file)
|
|
29
|
+
TuiTui::Pager.new(
|
|
30
|
+
"source: #{location}",
|
|
31
|
+
lines,
|
|
32
|
+
start: [target - 4, 0].max,
|
|
33
|
+
close_keys: ["S"],
|
|
34
|
+
theme: Theme.base
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def source_location(location)
|
|
41
|
+
return [nil, nil] unless location
|
|
42
|
+
|
|
43
|
+
file, _, line = location.rpartition(":")
|
|
44
|
+
[file, line.to_i]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def numbered_source(lines, target, file)
|
|
48
|
+
if lines.nil?
|
|
49
|
+
return [
|
|
50
|
+
"(source not found: #{file})",
|
|
51
|
+
"looked under: #{@resolver.roots.join(", ")}",
|
|
52
|
+
"pass --source-root DIR to point at the project root"
|
|
53
|
+
]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines.each_with_index.map do |text, index|
|
|
57
|
+
SourcePane.numbered_line(index + 1, text, target: target)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
require_relative "format"
|
|
5
|
+
|
|
6
|
+
module RSpecTelemetry
|
|
7
|
+
module Trace
|
|
8
|
+
module Viewer
|
|
9
|
+
class StatusLine
|
|
10
|
+
HINTS = "1-3=views Tab=pane f=follow q=quit"
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
document,
|
|
14
|
+
position:,
|
|
15
|
+
follow: false,
|
|
16
|
+
spinner: nil,
|
|
17
|
+
pending: false,
|
|
18
|
+
notice: nil,
|
|
19
|
+
total_ms: nil,
|
|
20
|
+
mode: nil
|
|
21
|
+
)
|
|
22
|
+
@document = document
|
|
23
|
+
@position = position
|
|
24
|
+
@follow = follow
|
|
25
|
+
@spinner = spinner
|
|
26
|
+
@pending = pending
|
|
27
|
+
@notice = notice
|
|
28
|
+
@total_ms = total_ms
|
|
29
|
+
@mode = mode
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def draw(canvas, rect)
|
|
33
|
+
right = "#{@position} #{@notice || HINTS} "
|
|
34
|
+
TuiTui::StatusBar.draw(canvas, rect, left: left_text, right: right, style: Theme::BAR)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def left_text
|
|
40
|
+
text = @mode ? " [#{@mode}] #{@document.status}" : " #{@document.events.size} events #{@document.status}"
|
|
41
|
+
total = Format.ms(@total_ms)
|
|
42
|
+
text += " #{total}" if total
|
|
43
|
+
return text unless @follow
|
|
44
|
+
|
|
45
|
+
text + " follow #{@spinner}#{@pending ? " pending" : ""}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
require_relative "label"
|
|
5
|
+
|
|
6
|
+
module RSpecTelemetry
|
|
7
|
+
module Trace
|
|
8
|
+
module Viewer
|
|
9
|
+
class TextReport
|
|
10
|
+
def initialize(document, depth: TuiTui::ColorDepth.detect, enabled: true)
|
|
11
|
+
@document = document
|
|
12
|
+
@depth = depth
|
|
13
|
+
@enabled = enabled
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
@document.entries.map { |entry| render_entry(entry) }.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def summary
|
|
21
|
+
count = @document.events.size
|
|
22
|
+
paint("#{count} events #{@document.status}", status_style)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def render_entry(entry)
|
|
28
|
+
prefix = entry.is_a?(Document::Action) ? "" : " "
|
|
29
|
+
prefix + Label.segments(entry).map { |segment| paint(segment.text, segment.style) }.join
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def paint(text, style_key)
|
|
33
|
+
Theme.style(style_key).paint(text, depth: @depth, enabled: @enabled)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def status_style
|
|
37
|
+
case @document.status
|
|
38
|
+
when "failed", "error", "timeout"
|
|
39
|
+
:error
|
|
40
|
+
when "ok"
|
|
41
|
+
:ok
|
|
42
|
+
else
|
|
43
|
+
:dim
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tui_tui"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
module Theme
|
|
9
|
+
S = TuiTui::Style
|
|
10
|
+
BASE = TuiTui::Theme.auto
|
|
11
|
+
|
|
12
|
+
STYLES = {
|
|
13
|
+
plain: BASE.text,
|
|
14
|
+
action: S.new(attrs: [:bold]),
|
|
15
|
+
dim: BASE.muted,
|
|
16
|
+
error: S.new(fg: :red, attrs: [:bold]),
|
|
17
|
+
ok: S.new(fg: :green)
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
SELECT = BASE.selection
|
|
21
|
+
SELECT_BLUR = BASE.selection_dim
|
|
22
|
+
BAR = BASE.bar
|
|
23
|
+
|
|
24
|
+
def self.base = BASE
|
|
25
|
+
|
|
26
|
+
def self.style(key) = STYLES.fetch(key)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
require_relative "format"
|
|
5
|
+
|
|
6
|
+
module RSpecTelemetry
|
|
7
|
+
module Trace
|
|
8
|
+
module Viewer
|
|
9
|
+
class TimeBar
|
|
10
|
+
LABEL = TuiTui::Style.new(attrs: [:bold])
|
|
11
|
+
FILL = TuiTui::Style.new(fg: :green)
|
|
12
|
+
TRACK = TuiTui::Style.new(attrs: [:dim])
|
|
13
|
+
|
|
14
|
+
def initialize(current_ms:, total_ms:)
|
|
15
|
+
@current = current_ms
|
|
16
|
+
@total = total_ms
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def draw(canvas, rect)
|
|
20
|
+
canvas.fill(rect, TRACK)
|
|
21
|
+
return canvas unless @total && @total.positive?
|
|
22
|
+
|
|
23
|
+
current = (@current || 0).clamp(0, @total)
|
|
24
|
+
percent = (current.to_f / @total * 100).round
|
|
25
|
+
label = " #{Format.ms(current)} / #{Format.ms(@total)} #{percent}% "
|
|
26
|
+
canvas.text(rect.row, rect.col, label, LABEL)
|
|
27
|
+
|
|
28
|
+
draw_bar(canvas, rect, current, TuiTui::DisplayText.new(label).width)
|
|
29
|
+
canvas
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def draw_bar(canvas, rect, current, label_width)
|
|
35
|
+
width = rect.cols - label_width
|
|
36
|
+
return if width < 4
|
|
37
|
+
|
|
38
|
+
col = rect.col + label_width
|
|
39
|
+
filled = (current.to_f / @total * width).round
|
|
40
|
+
canvas.text(rect.row, col, "=" * filled, FILL)
|
|
41
|
+
canvas.text(rect.row, col + filled, "-" * (width - filled), TRACK)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|