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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +193 -0
  5. data/examples/sample.ndjson +15 -0
  6. data/exe/rspec-telemetry +7 -0
  7. data/exe/rspec-telemetry-compare +6 -0
  8. data/exe/rspec-telemetry-viewer +67 -0
  9. data/lib/rspec_telemetry/analyzer.rb +170 -0
  10. data/lib/rspec_telemetry/cli.rb +71 -0
  11. data/lib/rspec_telemetry/compare_cli.rb +129 -0
  12. data/lib/rspec_telemetry/config.rb +40 -0
  13. data/lib/rspec_telemetry/console_report.rb +124 -0
  14. data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
  15. data/lib/rspec_telemetry/factory_comparison.rb +101 -0
  16. data/lib/rspec_telemetry/formatter.rb +91 -0
  17. data/lib/rspec_telemetry/ndjson.rb +24 -0
  18. data/lib/rspec_telemetry/recorder.rb +75 -0
  19. data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
  20. data/lib/rspec_telemetry/summary.rb +134 -0
  21. data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
  22. data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
  23. data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
  24. data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
  25. data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
  26. data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
  27. data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
  28. data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
  29. data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
  30. data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
  31. data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
  32. data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
  33. data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
  34. data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
  35. data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
  36. data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
  37. data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
  38. data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
  39. data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
  40. data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
  41. data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
  42. data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
  43. data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
  44. data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
  45. data/lib/rspec_telemetry/trace/viewer.rb +31 -0
  46. data/lib/rspec_telemetry/version.rb +5 -0
  47. data/lib/rspec_telemetry/writer.rb +59 -0
  48. data/lib/rspec_telemetry.rb +102 -0
  49. 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