rspec-tracer 1.1.0 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7f283b4a6ab3d0d2616f7dca4bac721906d36b4296ba1d64446937db1503dd5
4
- data.tar.gz: 2922f96420e344603a472b4e67bb5f2c58c16e8bf798e13098eec8f8096434c5
3
+ metadata.gz: 43ff2641751b61443860449bb5857cfeb2a4028881e05a7c58001f1bc6a7681d
4
+ data.tar.gz: aabe43c08122caf4e0af52ba77ef5ec85443953803488aae5d5bba83af7cdf6e
5
5
  SHA512:
6
- metadata.gz: 222031b5d7b0a6b01cad104fda83347a9e10af957c7a5d1dfede9f46cf8a275435c3e0c9dfb02ad7788b3dfc2d4c3b9513a60ee3ccf0d3bfd68a692758a38f1e
7
- data.tar.gz: f2828567b26b580ffc66568f8b14581cb160f4bdf14a61622b605b5832d5d3bc86cff211190628c5480604f0ec1c758609ec8aa490b357016e3565615c635be2
6
+ metadata.gz: 90b4b9795ff2b78e8c8a167e90736d639a74a4b4b082e69813cbaf66f4001852e603ad81ad761bb03565d7d4a0319d52ee3d0d79857b7880a7de82c1932b8640
7
+ data.tar.gz: 8784ef81e6fc6e076b64d22372215feeba7cfb7b5349b79b2b2e1618a745cced55c29e24ae768b941bda4f3a3058b28420ef4533a8b343f4e112d3bc9b47723f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
1
+ ## [1.1.2] - 2026-04-24
2
+
3
+ ### Fixed
4
+
5
+ - **Encoding crash on locale-unset shells** — legacy cache, report, and
6
+ coverage paths called `File.read` / `File.write` without an explicit
7
+ encoding. Ruby fell back to `Encoding.default_external`, which
8
+ resolves to `US-ASCII` on shells launched without `LANG` set (the
9
+ default for macOS GUI terminals and many LaunchAgent contexts). Any
10
+ spec description containing a non-ASCII byte (e.g. `§`, typographic
11
+ quotes) wrote UTF-8 into `all_examples.json`; the next warm run
12
+ crashed at `Cache#load_*_cache` with
13
+ `Encoding::InvalidByteSequenceError: "\xC2" on US-ASCII`, taking
14
+ `spec_helper` down before any example ran. Every legacy JSON / ERB /
15
+ Ruby-source read and write now pins `encoding: 'UTF-8'`;
16
+ `SourceFile.from_path` switches to `File.binread` so the MD5 digest
17
+ hashes raw bytes regardless of process locale.
18
+
19
+ ## [1.1.1] - 2026-04-23
20
+
21
+ ### Fixed
22
+
23
+ - **parallel_tests at-exit deadlock** — `parallel_tests_last_process?`
24
+ relied on a lock file written during `RSpecTracer.start` to identify
25
+ the last worker. If a fast worker reached `at_exit` before a slower
26
+ peer had loaded `spec_helper` and registered its `TEST_ENV_NUMBER`,
27
+ both workers could self-elect as the last process, both entered
28
+ `::ParallelTests.wait_for_other_processes_to_finish`, and deadlocked
29
+ on each other's pid. The elector now delegates to
30
+ `::ParallelTests.first_process?`, which reads immutable env vars set
31
+ by the parent at worker spawn. Exactly one worker is elected per run,
32
+ regardless of boot-time ordering or runner CPU count. No public-API
33
+ change — the `rspec_tracer.lock` file is still written and cleaned
34
+ up, just no longer consulted.
35
+
1
36
  ## [1.1.0] - 2026-04-20
2
37
 
3
38
  ### Added
@@ -98,7 +98,7 @@ module RSpecTracer
98
98
 
99
99
  return unless File.file?(file_name)
100
100
 
101
- JSON.parse(File.read(file_name))['run_id']
101
+ JSON.parse(File.read(file_name, encoding: 'UTF-8'))['run_id']
102
102
  end
103
103
 
104
104
  def load_all_examples_cache(cache_dir, discard_run_reason: true)
@@ -106,7 +106,7 @@ module RSpecTracer
106
106
 
107
107
  return unless File.file?(file_name)
108
108
 
109
- @all_examples = JSON.parse(File.read(file_name)).transform_values do |examples|
109
+ @all_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).transform_values do |examples|
110
110
  examples.transform_keys(&:to_sym)
111
111
  end
112
112
 
@@ -121,7 +121,7 @@ module RSpecTracer
121
121
 
122
122
  return unless File.file?(file_name)
123
123
 
124
- @duplicate_examples = JSON.parse(File.read(file_name)).transform_values do |examples|
124
+ @duplicate_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).transform_values do |examples|
125
125
  examples.map { |example| example.transform_keys(&:to_sym) }
126
126
  end
127
127
  end
@@ -131,7 +131,7 @@ module RSpecTracer
131
131
 
132
132
  return unless File.file?(file_name)
133
133
 
134
- @interrupted_examples = JSON.parse(File.read(file_name)).to_set
134
+ @interrupted_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).to_set
135
135
  end
136
136
 
137
137
  def load_flaky_examples_cache(cache_dir)
@@ -139,7 +139,7 @@ module RSpecTracer
139
139
 
140
140
  return unless File.file?(file_name)
141
141
 
142
- @flaky_examples = JSON.parse(File.read(file_name)).to_set
142
+ @flaky_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).to_set
143
143
  end
144
144
 
145
145
  def load_failed_examples_cache(cache_dir)
@@ -147,7 +147,7 @@ module RSpecTracer
147
147
 
148
148
  return unless File.file?(file_name)
149
149
 
150
- @failed_examples = JSON.parse(File.read(file_name)).to_set
150
+ @failed_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).to_set
151
151
  end
152
152
 
153
153
  def load_pending_examples_cache(cache_dir)
@@ -155,7 +155,7 @@ module RSpecTracer
155
155
 
156
156
  return unless File.file?(file_name)
157
157
 
158
- @pending_examples = JSON.parse(File.read(file_name)).to_set
158
+ @pending_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).to_set
159
159
  end
160
160
 
161
161
  def load_skipped_examples_cache(cache_dir)
@@ -163,7 +163,7 @@ module RSpecTracer
163
163
 
164
164
  return unless File.file?(file_name)
165
165
 
166
- @skipped_examples = JSON.parse(File.read(file_name)).to_set
166
+ @skipped_examples = JSON.parse(File.read(file_name, encoding: 'UTF-8')).to_set
167
167
  end
168
168
 
169
169
  def load_all_files_cache(cache_dir)
@@ -171,7 +171,7 @@ module RSpecTracer
171
171
 
172
172
  return unless File.file?(file_name)
173
173
 
174
- @all_files = JSON.parse(File.read(file_name)).transform_values do |files|
174
+ @all_files = JSON.parse(File.read(file_name, encoding: 'UTF-8')).transform_values do |files|
175
175
  files.transform_keys(&:to_sym)
176
176
  end
177
177
  end
@@ -181,7 +181,7 @@ module RSpecTracer
181
181
 
182
182
  return unless File.file?(file_name)
183
183
 
184
- @dependency = JSON.parse(File.read(file_name)).transform_values(&:to_set)
184
+ @dependency = JSON.parse(File.read(file_name, encoding: 'UTF-8')).transform_values(&:to_set)
185
185
  end
186
186
 
187
187
  def load_examples_coverage_cache(cache_dir)
@@ -189,7 +189,7 @@ module RSpecTracer
189
189
 
190
190
  return unless File.file?(file_name)
191
191
 
192
- @examples_coverage = JSON.parse(File.read(file_name))
192
+ @examples_coverage = JSON.parse(File.read(file_name, encoding: 'UTF-8'))
193
193
  end
194
194
  end
195
195
  end
@@ -14,7 +14,8 @@ module RSpecTracer
14
14
  reports_dir.each do |report_dir|
15
15
  next unless File.directory?(report_dir)
16
16
 
17
- cache_coverage = JSON.parse(File.read("#{report_dir}/coverage.json"))['RSpecTracer']['coverage']
17
+ raw = File.read("#{report_dir}/coverage.json", encoding: 'UTF-8')
18
+ cache_coverage = JSON.parse(raw)['RSpecTracer']['coverage']
18
19
 
19
20
  cache_coverage.each_pair do |file_name, line_coverage|
20
21
  unless @coverage.key?(file_name)
@@ -165,7 +165,7 @@ module RSpecTracer
165
165
 
166
166
  def jruby_line_stub(file_path)
167
167
  lines = File.foreach(file_path).map { nil }
168
- root_node = ::JRuby.parse(File.read(file_path))
168
+ root_node = ::JRuby.parse(File.read(file_path, encoding: 'UTF-8'))
169
169
 
170
170
  visitor = org.jruby.ast.visitor.NodeVisitor.impl do |_name, node|
171
171
  if node.newline?
@@ -15,7 +15,7 @@ module RSpecTracer
15
15
  }
16
16
  }
17
17
 
18
- File.write(@file_name, JSON.pretty_generate(report))
18
+ File.write(@file_name, JSON.pretty_generate(report), encoding: 'UTF-8')
19
19
  end
20
20
 
21
21
  def print_stats(elapsed_time)
@@ -211,7 +211,7 @@ module RSpecTracer
211
211
  end
212
212
 
213
213
  def template(name)
214
- ERB.new(File.read(File.join(File.dirname(__FILE__), 'views/', "#{name}.erb")))
214
+ ERB.new(File.read(File.join(File.dirname(__FILE__), 'views/', "#{name}.erb"), encoding: 'UTF-8'))
215
215
  end
216
216
 
217
217
  def example_status_css_class(example_status)
@@ -60,7 +60,7 @@ module RSpecTracer
60
60
 
61
61
  ref_list = @repo.branch_refs.merge(@repo.branch_ref => branch_ref_time.to_i)
62
62
 
63
- File.write(file_name, JSON.pretty_generate(ref_list))
63
+ File.write(file_name, JSON.pretty_generate(ref_list), encoding: 'UTF-8')
64
64
  end
65
65
 
66
66
  def last_run_id
@@ -68,7 +68,7 @@ module RSpecTracer
68
68
 
69
69
  raise CacheError, 'Could not find any local cache to upload' unless File.file?(file_name)
70
70
 
71
- JSON.parse(File.read(file_name))['run_id']
71
+ JSON.parse(File.read(file_name, encoding: 'UTF-8'))['run_id']
72
72
  end
73
73
  end
74
74
  end
@@ -169,7 +169,7 @@ module RSpecTracer
169
169
  file_name = File.join(RSpecTracer.cache_path, 'branch_refs.json')
170
170
 
171
171
  if @aws.download_branch_refs(branch_name, file_name)
172
- @branch_refs = JSON.parse(File.read(file_name)).transform_values(&:to_i)
172
+ @branch_refs = JSON.parse(File.read(file_name, encoding: 'UTF-8')).transform_values(&:to_i)
173
173
 
174
174
  return if @branch_refs.empty?
175
175
 
@@ -57,7 +57,7 @@ module RSpecTracer
57
57
 
58
58
  def merge_last_run_report(cache_dir)
59
59
  file_name = File.join(cache_dir, 'last_run.json')
60
- cached_last_run = JSON.parse(File.read(file_name), symbolize_names: true)
60
+ cached_last_run = JSON.parse(File.read(file_name, encoding: 'UTF-8'), symbolize_names: true)
61
61
  cached_last_run[:pid] = [cached_last_run[:pid]]
62
62
 
63
63
  cached_last_run.delete_if { |key, _| %i[run_id timestamp].include?(key) }
@@ -68,74 +68,74 @@ module RSpecTracer
68
68
  def write_all_examples_report
69
69
  file_name = File.join(@cache_dir, 'all_examples.json')
70
70
 
71
- File.write(file_name, JSON.pretty_generate(@reporter.all_examples))
71
+ File.write(file_name, JSON.pretty_generate(@reporter.all_examples), encoding: 'UTF-8')
72
72
  end
73
73
 
74
74
  def write_duplicate_examples_report
75
75
  file_name = File.join(@cache_dir, 'duplicate_examples.json')
76
76
 
77
- File.write(file_name, JSON.pretty_generate(@reporter.duplicate_examples))
77
+ File.write(file_name, JSON.pretty_generate(@reporter.duplicate_examples), encoding: 'UTF-8')
78
78
  end
79
79
 
80
80
  def write_interrupted_examples_report
81
81
  file_name = File.join(@cache_dir, 'interrupted_examples.json')
82
82
 
83
- File.write(file_name, JSON.pretty_generate(@reporter.interrupted_examples.sort.to_a))
83
+ File.write(file_name, JSON.pretty_generate(@reporter.interrupted_examples.sort.to_a), encoding: 'UTF-8')
84
84
  end
85
85
 
86
86
  def write_flaky_examples_report
87
87
  file_name = File.join(@cache_dir, 'flaky_examples.json')
88
88
 
89
- File.write(file_name, JSON.pretty_generate(@reporter.flaky_examples.sort.to_a))
89
+ File.write(file_name, JSON.pretty_generate(@reporter.flaky_examples.sort.to_a), encoding: 'UTF-8')
90
90
  end
91
91
 
92
92
  def write_failed_examples_report
93
93
  file_name = File.join(@cache_dir, 'failed_examples.json')
94
94
 
95
- File.write(file_name, JSON.pretty_generate(@reporter.failed_examples.sort.to_a))
95
+ File.write(file_name, JSON.pretty_generate(@reporter.failed_examples.sort.to_a), encoding: 'UTF-8')
96
96
  end
97
97
 
98
98
  def write_pending_examples_report
99
99
  file_name = File.join(@cache_dir, 'pending_examples.json')
100
100
 
101
- File.write(file_name, JSON.pretty_generate(@reporter.pending_examples.sort.to_a))
101
+ File.write(file_name, JSON.pretty_generate(@reporter.pending_examples.sort.to_a), encoding: 'UTF-8')
102
102
  end
103
103
 
104
104
  def write_skipped_examples_report
105
105
  file_name = File.join(@cache_dir, 'skipped_examples.json')
106
106
 
107
- File.write(file_name, JSON.pretty_generate(@reporter.skipped_examples.sort.to_a))
107
+ File.write(file_name, JSON.pretty_generate(@reporter.skipped_examples.sort.to_a), encoding: 'UTF-8')
108
108
  end
109
109
 
110
110
  def write_all_files_report
111
111
  file_name = File.join(@cache_dir, 'all_files.json')
112
112
 
113
- File.write(file_name, JSON.pretty_generate(@reporter.all_files))
113
+ File.write(file_name, JSON.pretty_generate(@reporter.all_files), encoding: 'UTF-8')
114
114
  end
115
115
 
116
116
  def write_dependency_report
117
117
  file_name = File.join(@cache_dir, 'dependency.json')
118
118
 
119
- File.write(file_name, JSON.pretty_generate(@reporter.dependency))
119
+ File.write(file_name, JSON.pretty_generate(@reporter.dependency), encoding: 'UTF-8')
120
120
  end
121
121
 
122
122
  def write_reverse_dependency_report
123
123
  file_name = File.join(@cache_dir, 'reverse_dependency.json')
124
124
 
125
- File.write(file_name, JSON.pretty_generate(@reporter.reverse_dependency))
125
+ File.write(file_name, JSON.pretty_generate(@reporter.reverse_dependency), encoding: 'UTF-8')
126
126
  end
127
127
 
128
128
  def write_examples_coverage_report
129
129
  file_name = File.join(@cache_dir, 'examples_coverage.json')
130
130
 
131
- File.write(file_name, JSON.pretty_generate(@reporter.examples_coverage))
131
+ File.write(file_name, JSON.pretty_generate(@reporter.examples_coverage), encoding: 'UTF-8')
132
132
  end
133
133
 
134
134
  def write_last_run_report
135
135
  file_name = File.join(@report_dir, 'last_run.json')
136
136
  last_run_data = @reporter.last_run.merge(run_id: @run_id, timestamp: Time.now.utc)
137
137
 
138
- File.write(file_name, JSON.pretty_generate(last_run_data))
138
+ File.write(file_name, JSON.pretty_generate(last_run_data), encoding: 'UTF-8')
139
139
  end
140
140
  end
141
141
  end
@@ -12,7 +12,7 @@ module RSpecTracer
12
12
  {
13
13
  file_path: file_path,
14
14
  file_name: file_name(file_path),
15
- digest: Digest::MD5.hexdigest(File.read(file_path))
15
+ digest: Digest::MD5.hexdigest(File.binread(file_path))
16
16
  }
17
17
  end
18
18
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTracer
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.2'
5
5
  end
data/lib/rspec_tracer.rb CHANGED
@@ -336,7 +336,7 @@ module RSpecTracer
336
336
 
337
337
  next unless File.directory?(cache_dir)
338
338
 
339
- run_id = JSON.parse(File.read(File.join(cache_dir, 'last_run.json')))['run_id']
339
+ run_id = JSON.parse(File.read(File.join(cache_dir, 'last_run.json'), encoding: 'UTF-8'))['run_id']
340
340
 
341
341
  reports_dir << File.join(cache_dir, run_id)
342
342
  end
@@ -416,18 +416,47 @@ module RSpecTracer
416
416
  true
417
417
  end
418
418
 
419
+ # Elects the worker that performs the per-run merge. Delegates to
420
+ # `::ParallelTests.first_process?`, which returns true iff
421
+ # `TEST_ENV_NUMBER.to_i <= 1` — i.e. for exactly one worker
422
+ # (TEST_ENV_NUMBER == '' or '1'), regardless of how many workers
423
+ # were actually spawned vs. how many CPUs the runner reports.
424
+ #
425
+ # Two previously attempted approaches do NOT work here:
426
+ #
427
+ # 1. The lock-file scheme below (each worker writing its
428
+ # TEST_ENV_NUMBER to `rspec_tracer.lock` via
429
+ # `track_parallel_tests_test_env_number`; last_process picked
430
+ # the max) deadlocked under slow CI: worker 1 could finish
431
+ # its examples before worker 2 even loaded spec_helper,
432
+ # observe itself as the max, and enter
433
+ # `::ParallelTests.wait_for_other_processes_to_finish`
434
+ # concurrently with worker 2's own self-election — both
435
+ # workers then spun on each other's pid.
436
+ #
437
+ # 2. `::ParallelTests.last_process?` compares TEST_ENV_NUMBER
438
+ # against PARALLEL_TEST_GROUPS, which parallel_rspec sets to
439
+ # the CPU-based *intended* process count — NOT the actual
440
+ # worker count. When spec files < CPU count (common), no
441
+ # TEST_ENV_NUMBER ever matches PARALLEL_TEST_GROUPS and the
442
+ # merge is silently skipped.
443
+ #
444
+ # `first_process?` avoids both: set by the parent at spawn,
445
+ # immutable thereafter, and identifies exactly one worker
446
+ # regardless of CPU count. The elected worker still calls
447
+ # `wait_for_other_processes_to_finish` before merging so peer
448
+ # caches are guaranteed on disk.
449
+ #
450
+ # `track_parallel_tests_test_env_number` and the lock-file
451
+ # cleanup in `at_exit_behavior` are retained for backward
452
+ # compatibility with users who observe `rspec_tracer.lock` /
453
+ # set `RSPEC_TRACER_LOCK_FILE`; the file is still written and
454
+ # removed but is no longer consulted.
419
455
  def parallel_tests_last_process?
420
456
  return false unless parallel_tests?
457
+ return false unless defined?(::ParallelTests)
421
458
 
422
- max_test_num = 0
423
-
424
- File.open(RSpecTracer.lock_file, 'r') do |f|
425
- f.flock(File::LOCK_SH)
426
-
427
- max_test_num = f.read.to_i
428
- end
429
-
430
- ENV['TEST_ENV_NUMBER'].to_i == max_test_num
459
+ ::ParallelTests.first_process?
431
460
  end
432
461
  end
433
462
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-tracer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abhimanyu Singh
@@ -111,7 +111,7 @@ licenses:
111
111
  - MIT
112
112
  metadata:
113
113
  homepage_uri: https://github.com/avmnu-sng/rspec-tracer
114
- source_code_uri: https://github.com/avmnu-sng/rspec-tracer/tree/v1.1.0
114
+ source_code_uri: https://github.com/avmnu-sng/rspec-tracer/tree/v1.1.2
115
115
  changelog_uri: https://github.com/avmnu-sng/rspec-tracer/blob/main/CHANGELOG.md
116
116
  bug_tracker_uri: https://github.com/avmnu-sng/rspec-tracer/issues
117
117
  rdoc_options: []