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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# isolated_execution_state only exists on Rails 7+; ActiveSupport::Notifications
|
|
4
|
+
# works without it, so make it optional to support Rails 6.x (and earlier).
|
|
5
|
+
begin
|
|
6
|
+
require "active_support/isolated_execution_state"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
require "active_support/notifications"
|
|
11
|
+
|
|
12
|
+
module RSpecTelemetry
|
|
13
|
+
module Subscribers
|
|
14
|
+
class FactoryBot
|
|
15
|
+
STACK_KEY = :rspec_telemetry_fb_stack
|
|
16
|
+
|
|
17
|
+
def initialize(recorder)
|
|
18
|
+
@recorder = recorder
|
|
19
|
+
@subscription = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def subscribe
|
|
23
|
+
@subscription = ActiveSupport::Notifications.subscribe("factory_bot.run_factory", self)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def unsubscribe
|
|
27
|
+
ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
|
|
28
|
+
@subscription = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start(_name, _id, payload)
|
|
32
|
+
RSpecTelemetry.safely("factory_bot#start") do
|
|
33
|
+
stack.push(
|
|
34
|
+
name: payload[:name].to_s,
|
|
35
|
+
monotonic: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
36
|
+
child_ms: 0.0
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def finish(_name, _id, payload)
|
|
42
|
+
RSpecTelemetry.safely("factory_bot#finish") do
|
|
43
|
+
frame = stack.pop
|
|
44
|
+
next unless frame
|
|
45
|
+
next unless @recorder.config.enabled && @recorder.config.capture_factory_bot
|
|
46
|
+
|
|
47
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
total = ((now - frame[:monotonic]) * 1000.0).round(3)
|
|
49
|
+
self_ms = (total - frame[:child_ms]).round(3)
|
|
50
|
+
|
|
51
|
+
parent = stack.last
|
|
52
|
+
parent[:child_ms] += total if parent
|
|
53
|
+
|
|
54
|
+
@recorder.record(
|
|
55
|
+
"factory_bot.run_factory",
|
|
56
|
+
factory: payload[:name].to_s,
|
|
57
|
+
strategy: payload[:strategy].to_s,
|
|
58
|
+
traits: Array(payload[:traits]).map(&:to_s),
|
|
59
|
+
overrides: override_names(payload[:overrides]),
|
|
60
|
+
duration_ms: total,
|
|
61
|
+
self_duration_ms: self_ms,
|
|
62
|
+
depth: stack.size,
|
|
63
|
+
parent_factory: parent && parent[:name],
|
|
64
|
+
factory_class: build_class_name(payload[:factory])
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def override_names(overrides)
|
|
72
|
+
return [] unless overrides.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
overrides.keys.map(&:to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_class_name(factory)
|
|
78
|
+
factory.build_class.to_s
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stack
|
|
84
|
+
Thread.current[STACK_KEY] ||= []
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "factory_aggregation"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
class Summary
|
|
7
|
+
ExampleStat = Struct.new(
|
|
8
|
+
:example_id,
|
|
9
|
+
:file_path,
|
|
10
|
+
:line_number,
|
|
11
|
+
:duration_ms,
|
|
12
|
+
:factory_bot_total_ms,
|
|
13
|
+
:factory_bot_count
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@factory_acc = FactoryAggregation::Accumulator.new
|
|
19
|
+
@examples = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(event)
|
|
23
|
+
case event[:type]
|
|
24
|
+
when "factory_bot.run_factory"
|
|
25
|
+
add_factory(event)
|
|
26
|
+
when "example.finished"
|
|
27
|
+
add_example(event)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def factories
|
|
32
|
+
@factory_acc.stats
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def examples
|
|
36
|
+
@examples.values
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def top_factories(limit = @config.summary_limit)
|
|
40
|
+
@factory_acc.top(limit)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def slow_examples(limit = @config.summary_limit)
|
|
44
|
+
sorted = examples.sort_by { |e| -e.duration_ms.to_f }
|
|
45
|
+
threshold = @config.slow_example_threshold_ms
|
|
46
|
+
sorted = sorted.select { |e| e.duration_ms.to_f >= threshold } if threshold
|
|
47
|
+
sorted.first(limit)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def add_factory(event)
|
|
53
|
+
self_ms = (event[:self_duration_ms] || event[:duration_ms]).to_f
|
|
54
|
+
@factory_acc.add(
|
|
55
|
+
factory: event[:factory],
|
|
56
|
+
strategy: event[:strategy],
|
|
57
|
+
duration_ms: event[:duration_ms],
|
|
58
|
+
self_duration_ms: self_ms
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
example_id = event[:example_id]
|
|
62
|
+
return unless example_id
|
|
63
|
+
|
|
64
|
+
ex = (@examples[example_id] ||= ExampleStat.new(example_id, nil, nil, nil, 0.0, 0))
|
|
65
|
+
ex.factory_bot_total_ms += self_ms
|
|
66
|
+
ex.factory_bot_count += 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_example(event)
|
|
70
|
+
example_id = event[:example_id]
|
|
71
|
+
return unless example_id
|
|
72
|
+
|
|
73
|
+
ex = (@examples[example_id] ||= ExampleStat.new(example_id, nil, nil, nil, 0.0, 0))
|
|
74
|
+
ex.file_path = event[:file_path]
|
|
75
|
+
ex.line_number = event[:line_number]
|
|
76
|
+
ex.duration_ms = event[:duration_ms]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module SummaryPrinter
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
def print(summary, config, io = config.summary_io)
|
|
84
|
+
return unless config.print_summary
|
|
85
|
+
|
|
86
|
+
lines = []
|
|
87
|
+
lines.concat(factory_section(summary, config))
|
|
88
|
+
lines.concat(example_section(summary, config))
|
|
89
|
+
return if lines.empty?
|
|
90
|
+
|
|
91
|
+
io.puts
|
|
92
|
+
io.puts(lines.join("\n"))
|
|
93
|
+
rescue => e
|
|
94
|
+
warn("[rspec-telemetry] failed to print summary: #{e.class}: #{e.message}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def factory_section(summary, _config)
|
|
98
|
+
tops = summary.top_factories
|
|
99
|
+
return [] if tops.empty?
|
|
100
|
+
|
|
101
|
+
lines = ["FactoryBot telemetry summary", "", "Top factories by self time (子factoryを除く):", ""]
|
|
102
|
+
tops.each_with_index do |f, i|
|
|
103
|
+
lines << "#{i + 1}. #{f.key}"
|
|
104
|
+
lines << " count: #{f.count}"
|
|
105
|
+
lines << " self_total: #{round(f.self_total_ms)}ms"
|
|
106
|
+
lines << " total: #{round(f.total_ms)}ms"
|
|
107
|
+
lines << " avg: #{round(f.avg_ms)}ms"
|
|
108
|
+
lines << " max: #{round(f.max_ms)}ms"
|
|
109
|
+
lines << ""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
lines
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def example_section(summary, _config)
|
|
116
|
+
slow = summary.slow_examples
|
|
117
|
+
return [] if slow.empty?
|
|
118
|
+
|
|
119
|
+
lines = ["Slow examples:", ""]
|
|
120
|
+
slow.each_with_index do |e, i|
|
|
121
|
+
lines << "#{i + 1}. #{e.example_id}"
|
|
122
|
+
lines << " duration: #{round(e.duration_ms)}ms"
|
|
123
|
+
lines << " factory_bot_total: #{round(e.factory_bot_total_ms)}ms (#{e.factory_bot_count} calls)"
|
|
124
|
+
lines << ""
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
lines
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def round(value)
|
|
131
|
+
value.to_f.round(1)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tui_tui"
|
|
4
|
+
|
|
5
|
+
require_relative "document"
|
|
6
|
+
require_relative "theme"
|
|
7
|
+
require_relative "layout"
|
|
8
|
+
require_relative "pane_resizer"
|
|
9
|
+
require_relative "source_view"
|
|
10
|
+
require_relative "app_renderer"
|
|
11
|
+
require_relative "follow_controller"
|
|
12
|
+
require_relative "screen/timeline_screen"
|
|
13
|
+
require_relative "screen/ranked_screen"
|
|
14
|
+
|
|
15
|
+
module RSpecTelemetry
|
|
16
|
+
module Trace
|
|
17
|
+
module Viewer
|
|
18
|
+
# Hosts shared TUI state; per-screen row behavior lives in Screen strategies.
|
|
19
|
+
class App
|
|
20
|
+
WHEEL_ROWS = 3
|
|
21
|
+
REDRAW = "\f"
|
|
22
|
+
|
|
23
|
+
HELP = [
|
|
24
|
+
["1 / 2 / 3", "timeline / slowest examples / factories"],
|
|
25
|
+
["j / k ↑ / ↓", "move"],
|
|
26
|
+
["Space / b", "page down / up"],
|
|
27
|
+
["g / G", "top / bottom"],
|
|
28
|
+
["n / N", "next / prev failure (timeline)"],
|
|
29
|
+
["Tab", "switch pane"],
|
|
30
|
+
["J / K", "scroll detail"],
|
|
31
|
+
["Enter", "fold / unfold this example"],
|
|
32
|
+
["z", "collapse to examples / expand"],
|
|
33
|
+
["f", "toggle follow"],
|
|
34
|
+
["/", "filter timeline"],
|
|
35
|
+
["a", "jump to example"],
|
|
36
|
+
["s", "toggle source pane"],
|
|
37
|
+
["S", "view full source"],
|
|
38
|
+
["mouse", "drag the divider / source header; wheel scrolls; click to focus"],
|
|
39
|
+
["Ctrl-L", "redraw the screen"],
|
|
40
|
+
["?", "this help"],
|
|
41
|
+
["q", "quit"]
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :detail_scroll
|
|
45
|
+
|
|
46
|
+
def focus = @focus_ring.current
|
|
47
|
+
def follow = @follow_ctl.active?
|
|
48
|
+
|
|
49
|
+
def initialize(
|
|
50
|
+
document,
|
|
51
|
+
depth: TuiTui::ColorDepth.detect,
|
|
52
|
+
source: nil,
|
|
53
|
+
follow: false,
|
|
54
|
+
base_dir: nil,
|
|
55
|
+
source_root: Dir.pwd
|
|
56
|
+
)
|
|
57
|
+
@document = document
|
|
58
|
+
@follow_ctl = FollowController.new(source: source, active: follow)
|
|
59
|
+
@source_view = SourceView.new(source_root: source_root, base_dir: base_dir)
|
|
60
|
+
@renderer = AppRenderer.new
|
|
61
|
+
@list = TuiTui::ScrollList.new
|
|
62
|
+
@detail_scroll = 0
|
|
63
|
+
@focus_ring = TuiTui::FocusRing.new(:timeline, :detail)
|
|
64
|
+
@modal = nil
|
|
65
|
+
@on_result = nil
|
|
66
|
+
@quit_armed = false
|
|
67
|
+
@resizer = PaneResizer.new
|
|
68
|
+
@size = nil
|
|
69
|
+
@source_visible = true
|
|
70
|
+
@view = :timeline
|
|
71
|
+
@timeline_screen = Screen::TimelineScreen.new(@document, @list)
|
|
72
|
+
@screen = @timeline_screen
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cursor = @list.cursor
|
|
76
|
+
|
|
77
|
+
def wants_tick? = @follow_ctl.wants_tick?
|
|
78
|
+
|
|
79
|
+
def redraw?(event) = event.is_a?(TuiTui::KeyEvent) && event.key == REDRAW
|
|
80
|
+
|
|
81
|
+
def update(event)
|
|
82
|
+
case event
|
|
83
|
+
when TuiTui::KeyEvent
|
|
84
|
+
@modal ? route_modal(event) : handle_key_event(event)
|
|
85
|
+
when TuiTui::MouseEvent
|
|
86
|
+
@modal ? route_modal(event) : handle_mouse(event)
|
|
87
|
+
when TuiTui::ResizeEvent
|
|
88
|
+
(@size = event.size) && self
|
|
89
|
+
when TuiTui::TickEvent
|
|
90
|
+
poll
|
|
91
|
+
else
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def view(ctx)
|
|
97
|
+
size = ctx.size
|
|
98
|
+
chrome = ctx.chrome
|
|
99
|
+
@size = size
|
|
100
|
+
r = layout(size)
|
|
101
|
+
result = @renderer.render(render_state(size, r, chrome))
|
|
102
|
+
@detail_scroll = result.detail_scroll
|
|
103
|
+
result.canvas
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def layout(size)
|
|
107
|
+
# Draw and hit-testing both use this geometry to keep mouse handles aligned.
|
|
108
|
+
Layout.compute(
|
|
109
|
+
size: size,
|
|
110
|
+
want_time_bar: @screen.time_bar? && !@document.end_wall_ms.nil?,
|
|
111
|
+
want_source: @screen.source? && @source_visible,
|
|
112
|
+
split_ratio: @resizer.split_ratio,
|
|
113
|
+
source_rows: @resizer.source_rows
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def open_modal(widget, &on_result)
|
|
118
|
+
@modal = widget
|
|
119
|
+
@on_result = on_result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def go_to(index)
|
|
123
|
+
@list.go_to(index)
|
|
124
|
+
@detail_scroll = 0
|
|
125
|
+
# Follow resumes only when the cursor is still parked at the tail.
|
|
126
|
+
@follow_ctl.reset_stick(at_end: @list.at_end?)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def route_modal(event)
|
|
132
|
+
# Route both keys and clicks so modal widgets (e.g. Confirm buttons)
|
|
133
|
+
# stay interactive with the mouse instead of being ignored.
|
|
134
|
+
result = event.is_a?(TuiTui::MouseEvent) ? @modal.handle_mouse(event) : @modal.handle(event.key)
|
|
135
|
+
return self if result.nil?
|
|
136
|
+
|
|
137
|
+
@modal = nil
|
|
138
|
+
@on_result.call(result) == :quit ? :quit : self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def confirm_quit
|
|
142
|
+
open_modal(TuiTui::Confirm.new("Quit the trace viewer?", theme: Theme.base)) { |r| :quit if r == :ok }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def handle_key_event(event)
|
|
146
|
+
armed = @quit_armed
|
|
147
|
+
@quit_armed = false
|
|
148
|
+
|
|
149
|
+
case event.key
|
|
150
|
+
when TuiTui::KeyCode::CTRL_C
|
|
151
|
+
# A second consecutive Ctrl-C exits without opening the quit modal.
|
|
152
|
+
return :quit if armed
|
|
153
|
+
|
|
154
|
+
@quit_armed = true
|
|
155
|
+
when "q"
|
|
156
|
+
confirm_quit
|
|
157
|
+
when "?"
|
|
158
|
+
open_modal(TuiTui::Help.new("Keys", HELP, theme: Theme.base)) { nil }
|
|
159
|
+
when "1"
|
|
160
|
+
set_view(:timeline)
|
|
161
|
+
when "2"
|
|
162
|
+
set_view(:examples)
|
|
163
|
+
when "3"
|
|
164
|
+
set_view(:factories)
|
|
165
|
+
when "s"
|
|
166
|
+
@source_visible = !@source_visible
|
|
167
|
+
when "S"
|
|
168
|
+
view_source
|
|
169
|
+
when "j", :down
|
|
170
|
+
move(1)
|
|
171
|
+
when "k", :up
|
|
172
|
+
move(-1)
|
|
173
|
+
when " ", :pgdn
|
|
174
|
+
move(page_rows)
|
|
175
|
+
when "b", :pgup
|
|
176
|
+
move(-page_rows)
|
|
177
|
+
when "g", :home
|
|
178
|
+
go_to(0)
|
|
179
|
+
when "G", :end
|
|
180
|
+
go_to(@list.last)
|
|
181
|
+
when "\t", :backtab
|
|
182
|
+
toggle_focus
|
|
183
|
+
when "f"
|
|
184
|
+
toggle_follow
|
|
185
|
+
when "J"
|
|
186
|
+
@detail_scroll += 1
|
|
187
|
+
when "K"
|
|
188
|
+
@detail_scroll = [@detail_scroll - 1, 0].max
|
|
189
|
+
else
|
|
190
|
+
@screen.handle_key_event(event, self)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def set_view(view)
|
|
197
|
+
return if view == @view
|
|
198
|
+
|
|
199
|
+
@view = view
|
|
200
|
+
@screen = view == :timeline ? @timeline_screen : Screen::RankedScreen.new(@document, @list, view)
|
|
201
|
+
@screen.activate
|
|
202
|
+
go_to(0)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def move(delta) = go_to(@list.cursor + delta)
|
|
206
|
+
|
|
207
|
+
def page_rows = @size ? [layout(@size).list.rows, 1].max : 1
|
|
208
|
+
|
|
209
|
+
def toggle_focus
|
|
210
|
+
@focus_ring = @focus_ring.next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def handle_mouse(event)
|
|
214
|
+
if event.action == :wheel
|
|
215
|
+
move(event.button == :wheel_up ? -WHEEL_ROWS : WHEEL_ROWS)
|
|
216
|
+
elsif @size
|
|
217
|
+
target = @resizer.handle(event, layout(@size))
|
|
218
|
+
@focus_ring = @focus_ring.focus(target) if target
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
self
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def toggle_follow
|
|
225
|
+
@follow_ctl.toggle(at_end: @list.at_end?)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def poll
|
|
229
|
+
# Order matters: refresh the row count after ingesting, then stick the
|
|
230
|
+
# cursor to the (new) tail.
|
|
231
|
+
@screen.refresh if @follow_ctl.drain(@document)
|
|
232
|
+
@follow_ctl.stick(@list)
|
|
233
|
+
self
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def view_source
|
|
237
|
+
pager = @source_view.pager(@screen.current_source)
|
|
238
|
+
open_modal(pager) { nil } if pager
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def position
|
|
242
|
+
count = @screen.count
|
|
243
|
+
return "0/0" if count.zero?
|
|
244
|
+
|
|
245
|
+
"#{@list.cursor + 1}/#{count}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def render_state(size, regions, chrome)
|
|
249
|
+
AppRenderer::State.new(
|
|
250
|
+
size: size,
|
|
251
|
+
chrome: chrome,
|
|
252
|
+
regions: regions,
|
|
253
|
+
document: @document,
|
|
254
|
+
screen: @screen,
|
|
255
|
+
list: @list,
|
|
256
|
+
focus_ring: @focus_ring,
|
|
257
|
+
source_view: @source_view,
|
|
258
|
+
modal: @modal,
|
|
259
|
+
detail_scroll: @detail_scroll,
|
|
260
|
+
quit_armed: @quit_armed,
|
|
261
|
+
follow: @follow_ctl.active?,
|
|
262
|
+
spinner: @follow_ctl.spinner,
|
|
263
|
+
position: position
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tui_tui"
|
|
4
|
+
|
|
5
|
+
require_relative "detail_pane"
|
|
6
|
+
require_relative "status_line"
|
|
7
|
+
require_relative "time_bar"
|
|
8
|
+
|
|
9
|
+
module RSpecTelemetry
|
|
10
|
+
module Trace
|
|
11
|
+
module Viewer
|
|
12
|
+
class AppRenderer
|
|
13
|
+
DIVIDER = TuiTui::Style.new(attrs: [:dim])
|
|
14
|
+
|
|
15
|
+
State = Data.define(
|
|
16
|
+
:size,
|
|
17
|
+
:chrome,
|
|
18
|
+
:regions,
|
|
19
|
+
:document,
|
|
20
|
+
:screen,
|
|
21
|
+
:list,
|
|
22
|
+
:focus_ring,
|
|
23
|
+
:source_view,
|
|
24
|
+
:modal,
|
|
25
|
+
:detail_scroll,
|
|
26
|
+
:quit_armed,
|
|
27
|
+
:follow,
|
|
28
|
+
:spinner,
|
|
29
|
+
:position
|
|
30
|
+
) do
|
|
31
|
+
def focused?(pane) = focus_ring.focused?(pane)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Result = Data.define(:canvas, :detail_scroll)
|
|
35
|
+
|
|
36
|
+
def render(state)
|
|
37
|
+
canvas = TuiTui::Canvas.blank(state.size, chrome: state.chrome)
|
|
38
|
+
state.list.ensure_visible(state.regions.list.rows)
|
|
39
|
+
|
|
40
|
+
TimeBar.new(current_ms: state.screen.time_bar_current, total_ms: state.document.end_wall_ms)
|
|
41
|
+
.draw(canvas, state.regions.time) if state.regions.time
|
|
42
|
+
state.screen.draw_list(canvas, state.regions.list, focused: state.focused?(:timeline))
|
|
43
|
+
detail_scroll = draw_detail(canvas, state)
|
|
44
|
+
draw_divider(canvas, state.regions) if state.regions.divider
|
|
45
|
+
state.source_view.draw(canvas, state.regions.source, state.screen.current_source) if state.regions.source
|
|
46
|
+
draw_status(canvas, state) if state.regions.status
|
|
47
|
+
state.modal&.draw(canvas, state.size)
|
|
48
|
+
|
|
49
|
+
Result.new(canvas: canvas, detail_scroll: detail_scroll || state.detail_scroll)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def draw_detail(canvas, state)
|
|
55
|
+
return nil unless state.regions.detail
|
|
56
|
+
|
|
57
|
+
width = state.regions.detail.split_gutter.first.cols
|
|
58
|
+
lines = state.screen.detail_lines(width)
|
|
59
|
+
scroll = state.detail_scroll.clamp(0, [lines.length - 1, 0].max)
|
|
60
|
+
DetailPane.new(lines, scroll: scroll).draw(canvas, state.regions.detail)
|
|
61
|
+
scroll
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def draw_divider(canvas, regions)
|
|
65
|
+
body = regions.body
|
|
66
|
+
rule = canvas.chrome.v
|
|
67
|
+
body.rows.times { |dr| canvas.text(body.row + dr, regions.divider, rule, DIVIDER) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def draw_status(canvas, state)
|
|
71
|
+
notice = "Ctrl-C again to quit" if state.quit_armed
|
|
72
|
+
StatusLine
|
|
73
|
+
.new(
|
|
74
|
+
state.document,
|
|
75
|
+
position: state.position,
|
|
76
|
+
notice: notice,
|
|
77
|
+
total_ms: state.document.end_wall_ms,
|
|
78
|
+
follow: state.follow,
|
|
79
|
+
spinner: state.spinner,
|
|
80
|
+
pending: state.document.pending?,
|
|
81
|
+
mode: state.screen.title
|
|
82
|
+
)
|
|
83
|
+
.draw(canvas, state.regions.status)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
require_relative "label"
|
|
5
|
+
require_relative "format"
|
|
6
|
+
require "tui_tui"
|
|
7
|
+
|
|
8
|
+
module RSpecTelemetry
|
|
9
|
+
module Trace
|
|
10
|
+
module Viewer
|
|
11
|
+
module DetailLines
|
|
12
|
+
def self.for(entry, children: [], duration: nil, width: nil)
|
|
13
|
+
return [] if entry.nil?
|
|
14
|
+
|
|
15
|
+
lines = if entry.is_a?(Document::Action)
|
|
16
|
+
example_lines(entry, children, duration)
|
|
17
|
+
else
|
|
18
|
+
event_lines(entry)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
return lines if width.nil?
|
|
22
|
+
|
|
23
|
+
lines.flat_map { |line| TuiTui::DisplayText.new(line).wrap(width, indent: " ").map(&:to_s) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.example_lines(action, children, duration)
|
|
27
|
+
lines = ["EXAMPLE", "desc: #{action.label}"]
|
|
28
|
+
lines << "at: #{action.source}" if action.source
|
|
29
|
+
lines << "status: #{action.status}" if action.status
|
|
30
|
+
took = action.duration_ms || duration
|
|
31
|
+
lines << "took: #{Format.ms(took)}" if took
|
|
32
|
+
lines.concat(exception_lines(action.exception))
|
|
33
|
+
lines.concat(children_lines(children))
|
|
34
|
+
lines
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.exception_lines(exception)
|
|
38
|
+
return [] if exception.nil?
|
|
39
|
+
|
|
40
|
+
["", "exception: #{exception["class"]}", " #{exception["message"]}"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.children_lines(children)
|
|
44
|
+
return [] if children.empty?
|
|
45
|
+
|
|
46
|
+
total = children.sum { |e| (e.fields["self_duration_ms"] || e.fields["duration_ms"]).to_f }
|
|
47
|
+
header = "ran #{children.size} factor#{children.size == 1 ? "y" : "ies"} (self #{Format.ms(total)}):"
|
|
48
|
+
[""] + [header] + children.map { |event| " #{Label.plain(event)}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.event_lines(event)
|
|
52
|
+
lines = [Label.plain(event), ""]
|
|
53
|
+
event.fields.each do |key, value|
|
|
54
|
+
next if Document::INFRA_FIELDS.include?(key)
|
|
55
|
+
|
|
56
|
+
lines.concat(field_lines(key, value))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
lines
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.field_lines(key, value)
|
|
63
|
+
case value
|
|
64
|
+
when Hash
|
|
65
|
+
["#{key}:"] + value.map { |k, v| " #{k}: #{Format.value(v)}" }
|
|
66
|
+
when Array
|
|
67
|
+
["#{key}:"] + value.map { |v| " - #{Format.value(v)}" }
|
|
68
|
+
else
|
|
69
|
+
["#{key}: #{Format.value(value)}"]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Trace
|
|
7
|
+
module Viewer
|
|
8
|
+
class DetailPane
|
|
9
|
+
def initialize(lines, scroll:)
|
|
10
|
+
@lines = lines
|
|
11
|
+
@scroll = scroll
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def length = @lines.length
|
|
15
|
+
|
|
16
|
+
def draw(canvas, rect)
|
|
17
|
+
TuiTui::TextView.draw(canvas, rect, top: @scroll, scrollbar: Theme.base, total: @lines.length) do |index|
|
|
18
|
+
line = @lines[index]
|
|
19
|
+
next nil if line.nil?
|
|
20
|
+
|
|
21
|
+
style = index.zero? ? Theme.style(:action) : Theme.style(:dim)
|
|
22
|
+
TuiTui::Line[TuiTui::Span[line, style]]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|