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,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../ndjson"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
# Incrementally folds an NDJSON trace into examples and their child events.
|
|
9
|
+
class Document
|
|
10
|
+
# The single source of truth for which statuses count as failures.
|
|
11
|
+
FAILED_STATUSES = %w[failed error].freeze
|
|
12
|
+
|
|
13
|
+
Action = Data.define(:seq, :verb, :label, :t, :source, :wall_ms, :status, :duration_ms, :exception) do
|
|
14
|
+
def failed? = FAILED_STATUSES.include?(status)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Event = Data.define(:seq, :op, :t, :action, :fields)
|
|
18
|
+
|
|
19
|
+
# Hidden from generic labels/details; the original fields remain on each event.
|
|
20
|
+
INFRA_FIELDS = %w[type seq op action wall_ms t timestamp monotonic_time pid thread_id].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader(
|
|
23
|
+
:entries,
|
|
24
|
+
:version,
|
|
25
|
+
:level,
|
|
26
|
+
:metadata,
|
|
27
|
+
:wall_time,
|
|
28
|
+
:failed_action,
|
|
29
|
+
:end_wall_ms,
|
|
30
|
+
:example_count,
|
|
31
|
+
:failure_count,
|
|
32
|
+
:pending_count
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def self.from_lines(lines)
|
|
36
|
+
lines.each_with_object(new) { |line, doc| doc.apply(line) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@entries = []
|
|
41
|
+
@actions_by_seq = {}
|
|
42
|
+
@action_by_example = {}
|
|
43
|
+
@current_action = nil
|
|
44
|
+
@seq = -1
|
|
45
|
+
@t0 = nil
|
|
46
|
+
@started = false
|
|
47
|
+
@ended = false
|
|
48
|
+
@status = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Unknown future event types are kept as generic events instead of dropped.
|
|
52
|
+
def apply(line)
|
|
53
|
+
parsed = parse(line)
|
|
54
|
+
return self unless parsed.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
@started = true
|
|
57
|
+
set_origin(parsed)
|
|
58
|
+
|
|
59
|
+
case parsed["type"]
|
|
60
|
+
when nil
|
|
61
|
+
self
|
|
62
|
+
when "example.started"
|
|
63
|
+
open_action(parsed)
|
|
64
|
+
when "example.finished"
|
|
65
|
+
close_action(parsed)
|
|
66
|
+
when "factory_bot.run_factory"
|
|
67
|
+
record("factory", parsed)
|
|
68
|
+
when "suite.finished"
|
|
69
|
+
finish(parsed)
|
|
70
|
+
else
|
|
71
|
+
record(parsed["type"], parsed)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def events = @entries.grep(Event)
|
|
78
|
+
|
|
79
|
+
def actions = @entries.grep(Action)
|
|
80
|
+
|
|
81
|
+
def action(seq) = @actions_by_seq[seq]
|
|
82
|
+
|
|
83
|
+
def events_for(action_seq)
|
|
84
|
+
@entries.select { |entry| entry.is_a?(Event) && entry.action == action_seq }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def pending? = @started && !@ended
|
|
88
|
+
|
|
89
|
+
def status
|
|
90
|
+
return @status if @status
|
|
91
|
+
return "ok" if @ended
|
|
92
|
+
|
|
93
|
+
"pending"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def parse(line) = Ndjson.parse(line)
|
|
99
|
+
|
|
100
|
+
# Normalize each process-local monotonic clock to t=0 for display.
|
|
101
|
+
def set_origin(parsed)
|
|
102
|
+
@t0 ||= parsed["monotonic_time"]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def wall_ms_of(parsed)
|
|
106
|
+
mono = parsed["monotonic_time"]
|
|
107
|
+
return nil if mono.nil? || @t0.nil?
|
|
108
|
+
|
|
109
|
+
((mono - @t0) * 1000.0).round(3)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def next_seq = (@seq += 1)
|
|
113
|
+
|
|
114
|
+
def open_action(parsed)
|
|
115
|
+
wall = wall_ms_of(parsed)
|
|
116
|
+
mark_wall(wall)
|
|
117
|
+
action = Action.new(
|
|
118
|
+
seq: next_seq,
|
|
119
|
+
verb: "example",
|
|
120
|
+
label: parsed["full_description"] || parsed["example_id"] || "(example)",
|
|
121
|
+
t: parsed["t"],
|
|
122
|
+
source: source_of(parsed),
|
|
123
|
+
wall_ms: wall,
|
|
124
|
+
status: nil,
|
|
125
|
+
duration_ms: nil,
|
|
126
|
+
exception: nil
|
|
127
|
+
)
|
|
128
|
+
@entries << action
|
|
129
|
+
@actions_by_seq[action.seq] = action
|
|
130
|
+
@action_by_example[parsed["example_id"]] = action.seq if parsed["example_id"]
|
|
131
|
+
@current_action = action.seq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Data objects are immutable, so finishing an example replaces its entry.
|
|
135
|
+
def close_action(parsed)
|
|
136
|
+
mark_wall(wall_ms_of(parsed))
|
|
137
|
+
seq = @action_by_example[parsed["example_id"]]
|
|
138
|
+
old = seq && @actions_by_seq[seq]
|
|
139
|
+
return if old.nil?
|
|
140
|
+
|
|
141
|
+
updated = old.with(
|
|
142
|
+
status: parsed["status"],
|
|
143
|
+
duration_ms: parsed["duration_ms"],
|
|
144
|
+
exception: exception_of(parsed)
|
|
145
|
+
)
|
|
146
|
+
@actions_by_seq[seq] = updated
|
|
147
|
+
@failed_action = seq if updated.failed?
|
|
148
|
+
index = @entries.index(old)
|
|
149
|
+
@entries[index] = updated if index
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def record(op, parsed)
|
|
153
|
+
wall = wall_ms_of(parsed)
|
|
154
|
+
mark_wall(wall)
|
|
155
|
+
owner = @action_by_example[parsed["example_id"]] || @current_action
|
|
156
|
+
event = Event.new(
|
|
157
|
+
seq: next_seq,
|
|
158
|
+
op: op,
|
|
159
|
+
t: parsed["t"],
|
|
160
|
+
action: owner,
|
|
161
|
+
fields: parsed.merge("wall_ms" => wall)
|
|
162
|
+
)
|
|
163
|
+
@entries << event
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def finish(parsed)
|
|
167
|
+
@ended = true
|
|
168
|
+
@example_count = parsed["example_count"]
|
|
169
|
+
@failure_count = parsed["failure_count"]
|
|
170
|
+
@pending_count = parsed["pending_count"]
|
|
171
|
+
@status = parsed["failure_count"].to_i.positive? ? "failed" : "ok"
|
|
172
|
+
mark_wall(wall_ms_of(parsed))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def mark_wall(wall)
|
|
176
|
+
return if wall.nil?
|
|
177
|
+
|
|
178
|
+
@end_wall_ms = wall if @end_wall_ms.nil? || wall > @end_wall_ms
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def source_of(parsed)
|
|
182
|
+
file = parsed["file_path"]
|
|
183
|
+
return nil if file.nil?
|
|
184
|
+
|
|
185
|
+
line = parsed["line_number"]
|
|
186
|
+
line ? "#{file}:#{line}" : file
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def exception_of(parsed)
|
|
190
|
+
klass = parsed["exception_class"]
|
|
191
|
+
return nil if klass.nil?
|
|
192
|
+
|
|
193
|
+
{"class" => klass, "message" => parsed["exception_message"]}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
module Trace
|
|
5
|
+
module Viewer
|
|
6
|
+
# Owns "follow mode": draining a live source into the Document, keeping the
|
|
7
|
+
# cursor parked at the tail, and advancing the pending spinner.
|
|
8
|
+
class FollowController
|
|
9
|
+
SPINNER = %w[| / - \\].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(source:, active:)
|
|
12
|
+
@source = source
|
|
13
|
+
@active = active
|
|
14
|
+
@stick = active
|
|
15
|
+
@spin = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def active? = @active
|
|
19
|
+
def wants_tick? = @active
|
|
20
|
+
def spinner = SPINNER[@spin % SPINNER.length]
|
|
21
|
+
|
|
22
|
+
# Flip follow on/off; resume tail-sticking only when parked at the end.
|
|
23
|
+
def toggle(at_end:)
|
|
24
|
+
@active = !@active
|
|
25
|
+
@stick = @active && at_end
|
|
26
|
+
@active
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Re-evaluate sticking after the cursor moves (e.g. App#go_to).
|
|
30
|
+
def reset_stick(at_end:)
|
|
31
|
+
@stick = @active && at_end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Advance the spinner and fold any new source lines into the document.
|
|
35
|
+
def drain(document)
|
|
36
|
+
return false unless @active
|
|
37
|
+
|
|
38
|
+
@spin += 1
|
|
39
|
+
lines = @source ? @source.drain : []
|
|
40
|
+
lines.each { |line| document.apply(line) }
|
|
41
|
+
!lines.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Keep the cursor parked at the tail while sticking.
|
|
45
|
+
def stick(list)
|
|
46
|
+
list.to_end if @stick
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
module Trace
|
|
5
|
+
module Viewer
|
|
6
|
+
module Format
|
|
7
|
+
def self.ms(value)
|
|
8
|
+
return nil if value.nil?
|
|
9
|
+
|
|
10
|
+
value >= 1000 ? "#{(value / 1000.0).round(2)}s" : "#{value.round}ms"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Inspect a value deterministically across Ruby versions (Ruby 3.4 changed
|
|
14
|
+
# Hash#inspect from `{"k"=>v}` to `{"k" => v}`). Recurses into Hashes.
|
|
15
|
+
def self.value(obj)
|
|
16
|
+
return "{#{obj.map { |key, val| "#{key.inspect} => #{value(val)}" }.join(", ")}}" if obj.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
obj.inspect
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
require_relative "format"
|
|
5
|
+
|
|
6
|
+
module RSpecTelemetry
|
|
7
|
+
module Trace
|
|
8
|
+
module Viewer
|
|
9
|
+
module Label
|
|
10
|
+
Segment = Data.define(:text, :style)
|
|
11
|
+
|
|
12
|
+
def self.segments(entry)
|
|
13
|
+
return action_segments(entry) if entry.is_a?(Document::Action)
|
|
14
|
+
|
|
15
|
+
event_segments(entry)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.plain(entry) = segments(entry).map(&:text).join
|
|
19
|
+
|
|
20
|
+
def self.category(entry)
|
|
21
|
+
if entry.is_a?(Document::Action)
|
|
22
|
+
return entry.failed? ? :error : :action
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
case entry.op
|
|
26
|
+
when "factory"
|
|
27
|
+
entry.fields["depth"].to_i.positive? ? :dim : :plain
|
|
28
|
+
else
|
|
29
|
+
:plain
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.action_segments(action)
|
|
34
|
+
tag = action.failed? ? " [#{action.status.upcase}]" : ""
|
|
35
|
+
style = action.failed? ? :error : :action
|
|
36
|
+
segments = [seg("EXAMPLE #{action.label}#{tag}", style)]
|
|
37
|
+
segments << seg(" at: #{action.source}", :dim) if action.source
|
|
38
|
+
segments
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.event_segments(event)
|
|
42
|
+
case event.op
|
|
43
|
+
when "factory"
|
|
44
|
+
factory_segments(event.fields)
|
|
45
|
+
else
|
|
46
|
+
[seg("#{event.op.upcase} #{compact(extra(event.fields))}", :plain)]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.factory_segments(fields)
|
|
51
|
+
depth = fields["depth"].to_i
|
|
52
|
+
style = depth.positive? ? :dim : :plain
|
|
53
|
+
[seg("#{" " * depth}FACTORY #{name(fields)}#{traits(fields["traits"])} #{timing(fields)}", style)]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.name(fields) = "#{fields["factory"]}:#{fields["strategy"]}"
|
|
57
|
+
|
|
58
|
+
def self.timing(fields)
|
|
59
|
+
total = Format.ms(fields["duration_ms"])
|
|
60
|
+
self_ms = fields["self_duration_ms"]
|
|
61
|
+
return total.to_s unless self_ms && self_ms != fields["duration_ms"]
|
|
62
|
+
|
|
63
|
+
"#{total} (self #{Format.ms(self_ms)})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.traits(list)
|
|
67
|
+
list.nil? || list.empty? ? "" : " [#{Array(list).join(",")}]"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.extra(fields)
|
|
71
|
+
fields.reject { |key, _| Document::INFRA_FIELDS.include?(key) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.seg(text, style) = Segment.new(text: text, style: style)
|
|
75
|
+
|
|
76
|
+
def self.compact(value)
|
|
77
|
+
return "" if value.nil? || value.empty?
|
|
78
|
+
|
|
79
|
+
Format.value(value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tui_tui"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
# Pure geometry shared by rendering and mouse hit-testing.
|
|
9
|
+
module Layout
|
|
10
|
+
MIN_TWO_PANE = 60
|
|
11
|
+
GUTTER = 1
|
|
12
|
+
MIN_PANE_COLS = 12
|
|
13
|
+
MIN_SOURCE_ROWS = 3
|
|
14
|
+
MIN_BODY_ROWS = 4
|
|
15
|
+
|
|
16
|
+
Regions = Data.define(
|
|
17
|
+
:time,
|
|
18
|
+
:body,
|
|
19
|
+
:list,
|
|
20
|
+
:detail,
|
|
21
|
+
:divider,
|
|
22
|
+
:source,
|
|
23
|
+
:source_top,
|
|
24
|
+
:content,
|
|
25
|
+
:status
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Regions are nil when the terminal is too small or a feature is hidden.
|
|
29
|
+
def self.compute(size:, want_time_bar:, want_source:, split_ratio:, source_rows:)
|
|
30
|
+
return single(whole(size)) if size.rows < 2
|
|
31
|
+
|
|
32
|
+
body, status = whole(size).split_h(size.rows - 1)
|
|
33
|
+
time, body = carve_time_bar(body) if want_time_bar
|
|
34
|
+
content, body, source, source_top = carve_source(body, source_rows) if want_source
|
|
35
|
+
list, detail, divider = split_panes(body, split_ratio)
|
|
36
|
+
|
|
37
|
+
Regions.new(
|
|
38
|
+
time: time,
|
|
39
|
+
body: body,
|
|
40
|
+
list: list,
|
|
41
|
+
detail: detail,
|
|
42
|
+
divider: divider,
|
|
43
|
+
source: source,
|
|
44
|
+
source_top: source_top,
|
|
45
|
+
content: content,
|
|
46
|
+
status: status
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.source_height(content, source_rows)
|
|
51
|
+
# Keep both source and main body usable while dragging.
|
|
52
|
+
lo = MIN_SOURCE_ROWS
|
|
53
|
+
hi = content.rows - MIN_BODY_ROWS
|
|
54
|
+
return nil if hi < lo
|
|
55
|
+
|
|
56
|
+
source_rows.clamp(lo, hi)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.single(body)
|
|
60
|
+
Regions.new(
|
|
61
|
+
time: nil,
|
|
62
|
+
body: body,
|
|
63
|
+
list: body,
|
|
64
|
+
detail: nil,
|
|
65
|
+
divider: nil,
|
|
66
|
+
source: nil,
|
|
67
|
+
source_top: nil,
|
|
68
|
+
content: nil,
|
|
69
|
+
status: nil
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.carve_time_bar(body)
|
|
74
|
+
return [nil, body] if body.rows < 2
|
|
75
|
+
|
|
76
|
+
body.split_h(1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.carve_source(body, source_rows)
|
|
80
|
+
return [nil, body, nil, nil] if body.rows < MIN_BODY_ROWS + MIN_SOURCE_ROWS + 1
|
|
81
|
+
|
|
82
|
+
height = source_height(body, source_rows)
|
|
83
|
+
return [nil, body, nil, nil] if height.nil?
|
|
84
|
+
|
|
85
|
+
top, source = body.split_h(body.rows - height)
|
|
86
|
+
[body, top, source, source.row]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.split_panes(body, ratio)
|
|
90
|
+
return [body, nil, nil] if body.cols < MIN_TWO_PANE
|
|
91
|
+
|
|
92
|
+
left, detail = body.split_ratio(ratio, min: MIN_PANE_COLS, gutter: GUTTER)
|
|
93
|
+
[left, detail, left.col + left.cols]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.whole(size) = TuiTui::Rect.new(row: 1, col: 1, rows: size.rows, cols: size.cols)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "layout"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
# Owns mouse drag state and reports plain clicks back as focus targets.
|
|
9
|
+
class PaneResizer
|
|
10
|
+
DEFAULT_SOURCE_ROWS = 10
|
|
11
|
+
|
|
12
|
+
attr_reader :split_ratio, :source_rows
|
|
13
|
+
|
|
14
|
+
def initialize(split_ratio: 0.5, source_rows: DEFAULT_SOURCE_ROWS)
|
|
15
|
+
@split_ratio = split_ratio
|
|
16
|
+
@source_rows = source_rows
|
|
17
|
+
@dragging = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle(event, region)
|
|
21
|
+
case event.action
|
|
22
|
+
when :press
|
|
23
|
+
return press(event, region)
|
|
24
|
+
when :drag
|
|
25
|
+
drag(event, region)
|
|
26
|
+
when :release
|
|
27
|
+
@dragging = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def press(event, region)
|
|
36
|
+
if on_source_header?(event.row, region)
|
|
37
|
+
@dragging = :source
|
|
38
|
+
nil
|
|
39
|
+
elsif near_divider?(event.col, region)
|
|
40
|
+
@dragging = :divider
|
|
41
|
+
nil
|
|
42
|
+
else
|
|
43
|
+
focus_target(event.col, region)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def drag(event, region)
|
|
48
|
+
case @dragging
|
|
49
|
+
when :divider
|
|
50
|
+
drag_divider(event.col, region)
|
|
51
|
+
when :source
|
|
52
|
+
drag_source(event.row, region)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def near_divider?(col, region)
|
|
57
|
+
!region.divider.nil? && (col - region.divider).abs <= 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def drag_divider(col, region)
|
|
61
|
+
body = region.body
|
|
62
|
+
return unless region.divider
|
|
63
|
+
|
|
64
|
+
left_cols = (col - body.col).clamp(
|
|
65
|
+
Layout::MIN_PANE_COLS,
|
|
66
|
+
body.cols - Layout::MIN_PANE_COLS - Layout::GUTTER
|
|
67
|
+
)
|
|
68
|
+
@split_ratio = left_cols.to_f / body.cols
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_source_header?(row, region)
|
|
72
|
+
!region.source_top.nil? && row == region.source_top
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def drag_source(row, region)
|
|
76
|
+
content = region.content
|
|
77
|
+
return unless content
|
|
78
|
+
|
|
79
|
+
bottom = content.row + content.rows - 1
|
|
80
|
+
@source_rows = (bottom - row + 1).clamp(
|
|
81
|
+
Layout::MIN_SOURCE_ROWS,
|
|
82
|
+
content.rows - Layout::MIN_BODY_ROWS
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def focus_target(col, region)
|
|
87
|
+
return :timeline if in_rect?(region.list, col)
|
|
88
|
+
return :detail if in_rect?(region.detail, col)
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def in_rect?(rect, col)
|
|
94
|
+
!rect.nil? && col >= rect.col && col < rect.col + rect.cols
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
class ReportPane
|
|
9
|
+
def initialize(rows, list:, focus:)
|
|
10
|
+
@rows = rows
|
|
11
|
+
@list = list
|
|
12
|
+
@focus = focus
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def draw(canvas, rect)
|
|
16
|
+
highlight = @focus ? Theme::SELECT : Theme::SELECT_BLUR
|
|
17
|
+
TuiTui::List.new(@list).draw(canvas, rect, highlight: highlight, scrollbar: Theme.base) do |index, selected|
|
|
18
|
+
row = @rows[index]
|
|
19
|
+
style = selected ? highlight : Theme.style(row.style)
|
|
20
|
+
TuiTui::Line[TuiTui::Span[row.text, style]]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
require_relative "format"
|
|
5
|
+
require_relative "../../factory_aggregation"
|
|
6
|
+
|
|
7
|
+
module RSpecTelemetry
|
|
8
|
+
module Trace
|
|
9
|
+
module Viewer
|
|
10
|
+
module ReportView
|
|
11
|
+
Row = Data.define(:text, :style, :source, :payload)
|
|
12
|
+
|
|
13
|
+
TITLES = {examples: "slowest examples", factories: "factories by self time"}.freeze
|
|
14
|
+
|
|
15
|
+
def self.title(view) = TITLES[view]
|
|
16
|
+
|
|
17
|
+
def self.rows(document, view)
|
|
18
|
+
case view
|
|
19
|
+
when :examples
|
|
20
|
+
example_rows(document)
|
|
21
|
+
when :factories
|
|
22
|
+
factory_rows(document)
|
|
23
|
+
else
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.example_rows(document)
|
|
29
|
+
document.actions.sort_by { |action| -(action.duration_ms || 0.0) }.map do |action|
|
|
30
|
+
tag = action.failed? ? " [#{action.status.upcase}]" : ""
|
|
31
|
+
Row.new(
|
|
32
|
+
text: "#{dur(action.duration_ms)} #{action.label}#{tag}",
|
|
33
|
+
style: action.failed? ? :error : :plain,
|
|
34
|
+
source: action.source,
|
|
35
|
+
payload: action
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.factory_rows(document)
|
|
41
|
+
aggregate(document).top.map do |stat|
|
|
42
|
+
Row.new(
|
|
43
|
+
text: "#{dur(stat.self_total_ms)} #{stat.key} ×#{stat.count} total #{Format.ms(stat.total_ms)}",
|
|
44
|
+
style: :plain,
|
|
45
|
+
source: nil,
|
|
46
|
+
payload: stat
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.detail(payload)
|
|
52
|
+
return [] unless payload.is_a?(FactoryAggregation::Stat)
|
|
53
|
+
|
|
54
|
+
[
|
|
55
|
+
"FACTORY #{payload.key}",
|
|
56
|
+
"",
|
|
57
|
+
"count: #{payload.count}",
|
|
58
|
+
"self total: #{Format.ms(payload.self_total_ms)} (children excluded)",
|
|
59
|
+
"total: #{Format.ms(payload.total_ms)} (children included)",
|
|
60
|
+
"avg: #{Format.ms(payload.avg_ms)}",
|
|
61
|
+
"max: #{Format.ms(payload.max_ms)}"
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.aggregate(document)
|
|
66
|
+
acc = FactoryAggregation::Accumulator.new
|
|
67
|
+
document.events.each do |event|
|
|
68
|
+
next unless event.op == "factory"
|
|
69
|
+
|
|
70
|
+
fields = event.fields
|
|
71
|
+
acc.add(
|
|
72
|
+
factory: fields["factory"],
|
|
73
|
+
strategy: fields["strategy"],
|
|
74
|
+
duration_ms: fields["duration_ms"],
|
|
75
|
+
self_duration_ms: fields["self_duration_ms"]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
acc
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.dur(ms) = (Format.ms(ms) || "-").rjust(8)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|