rails-quality-assurance 1.4.2 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c48fd8ab4bfffb99f79aed4c20a235d68a8743572688e16a30e92fb70c3653b
4
- data.tar.gz: 316329537a41a4259bbc2ed9f69767dceb4d491af9d5314fa9e1f2241ff79f73
3
+ metadata.gz: 82e64c7980fb29c2994a22bf443e2215380a1dd16a9810d1790770624f47854a
4
+ data.tar.gz: 02dc1dd0d3537f7ededa2b4e2e7dca210b6142f2392f3383b150da0c6118697f
5
5
  SHA512:
6
- metadata.gz: db9440d4c8d0524b9e1dbaec2d7ab1e5c8f506e86c0aedbeee6d9e1f194589076cc97d3842687789fb50087fb723e30aa40138b5e5c7870dec7964b7b038addb
7
- data.tar.gz: f667a8d871426ae41141ad849368f71a69b26512a18376cf5a17c92d93fab62fbfee6f3c83adcc416e8bf9f69be0008cd2af155d2c7e713c6c50b69ee665c3f2
6
+ metadata.gz: 8be0b839b55b6c9af8aa85110e47fdd1d394c2d5e279a07e51817d32cac9622734c74d1e79e4e9e33243d3deb3ff6c0e4bf35440f771f1a50a641c74d8fabdfe
7
+ data.tar.gz: 0f609c8d4f8cba9ea6b93a60ba8121502efbcda7bdfa815319fa31196b527d84126d52c90ffc37ea49b2aaf49e5cd17735c3e08aa085f00693cf66734b16c7a5
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.0] - 2026-09-25
9
+
10
+ ### Added
11
+
12
+ - `spec:parallel` holds a cross-process `flock` lock
13
+ (`tmp/qa/spec_parallel.lock`). A second run aborts immediately with the
14
+ holder's command, PID, and elapsed time instead of piling workers onto the
15
+ machine. The kernel releases the lock when the holder exits, including on
16
+ SIGKILL, and the descriptor is inherited by spawned workers so the lock
17
+ outlives a killed coordinator for as long as any worker keeps running.
18
+
19
+ ## [1.5.0] - 2026-09-25
20
+
21
+ ### Changed
22
+
23
+ - `spec:parallel` defaults `PARALLEL_TEST_PROCESSORS` to the number of available
24
+ CPUs instead of a hardcoded 3. On a 6-CPU machine the default run uses 6
25
+ workers: 5 for the regular suite plus 1 isolated worker for `spec/system/`.
26
+
27
+ ### Fixed
28
+
29
+ - `ISOLATE_SPEC_TASKS=0` now disables system spec isolation. `--isolate-n 0`
30
+ was silently ignored by parallel_tests, which still reserved a dedicated
31
+ worker and kept system specs out of the regular grouping.
32
+
8
33
  ## [1.4.2] - 2026-09-16
9
34
 
10
35
  ### Fixed
data/README.md CHANGED
@@ -27,7 +27,9 @@ Opinionated quality assurance kit for Ruby on Rails applications.
27
27
 
28
28
  - **RSpec**: Shared helpers and configuration.
29
29
  - **Playwright + Capybara**: Remote Chromium headless driver support with auto port assignment for parallel workers and HTML error state dumps on failure (`require 'rails_quality_assurance/playwright'`).
30
- - **Parallel Tests**: `rake spec:parallel` task with automated system test isolation.
30
+ - **Parallel Tests**: `rake spec:parallel` task that matches the CPU count by
31
+ default, isolates system specs into a dedicated worker, and serializes runs
32
+ behind a cross-process lock.
31
33
  - **SimpleCov**: 100% line and branch coverage threshold enforcement, LCOV reporting, and parallel process reporting (`require 'rails_quality_assurance/simplecov'`).
32
34
 
33
35
  ### 4. Continuous Integration Harness
@@ -84,6 +86,35 @@ That single require sets up:
84
86
 
85
87
  *(If you prefer to load them individually, `require 'rails_quality_assurance/simplecov'` and `require 'rails_quality_assurance/playwright'` remain available.)*
86
88
 
89
+ #### Configuring SimpleCov (`.simplecov`)
90
+
91
+ `rails_quality_assurance` requires SimpleCov and then calls
92
+ `SimpleCov.start 'rails'`. SimpleCov automatically loads a `.simplecov` file
93
+ from the project root while `require 'simplecov'` runs, so anything configured
94
+ there is applied **before** coverage starts:
95
+
96
+ ```ruby
97
+ # .simplecov
98
+ SimpleCov.configure do
99
+ cover_views # measure ActionView templates (SimpleCov 1.2+)
100
+ track_tests # record which test covered each line
101
+ group 'Components', 'app/components'
102
+ skip 'app/views/pwa'
103
+ end
104
+ ```
105
+
106
+ Use SimpleCov's native DSL for any option the gem does not wrap — `cover`,
107
+ `track_tests`, `group`, `minimum_coverage`, `maximum_coverage_drop`,
108
+ `baseline_file`, and so on. Because SimpleCov reads the file itself, a new
109
+ SimpleCov option is available to a project without a new
110
+ `rails-quality-assurance` release.
111
+
112
+ `.simplecov` is loaded with plain `load`, so bare method calls resolve against
113
+ `main`. Wrap them in `SimpleCov.configure` (which evaluates the block in
114
+ SimpleCov's context, as above) or prefix each call with `SimpleCov.`. Keep it
115
+ to configuration only: `SimpleCov.start` belongs in `spec/spec_helper.rb`, and
116
+ SimpleCov 1.3 deprecates calling it from `.simplecov`.
117
+
87
118
  ### 3. Rails Test Setup (spec/rails_helper.rb)
88
119
 
89
120
  After the Rails environment boots, one require applies the shared Rails test configuration:
@@ -138,6 +169,27 @@ bin/rails qa:frontend # Runs Biome and Stylelint
138
169
  bin/rails spec:parallel # Runs RSpec in parallel with system specs isolated
139
170
  ```
140
171
 
172
+ `spec:parallel` reads these environment variables:
173
+
174
+ | Variable | Default | Purpose |
175
+ | --- | --- | --- |
176
+ | `PARALLEL_TEST_PROCESSORS` | Number of CPUs | Total worker count |
177
+ | `ISOLATE_SPEC_TASKS` | `1` (or `0` on a single CPU) | Workers reserved for `spec/system/` |
178
+ | `PARALLEL_SPEC_OPTIONS` | - | Extra options passed to RSpec |
179
+
180
+ Isolation workers are carved out of the total. On a 6-CPU machine the default
181
+ run uses 6 workers: 5 for the regular suite and 1 isolated worker for
182
+ `spec/system/`. Set `ISOLATE_SPEC_TASKS=0` to skip isolation entirely; system
183
+ specs then run as ordinary specs.
184
+
185
+ `spec:parallel` takes an exclusive `flock` on `tmp/qa/spec_parallel.lock` for
186
+ the whole run. Starting a second run while one is in progress aborts
187
+ immediately with the holder's command, PID, and elapsed time instead of
188
+ doubling the worker count. The kernel releases the lock when the holder exits,
189
+ including on SIGKILL, so a killed run never leaves a stale lock behind; spawned
190
+ workers inherit the descriptor, so the lock stays held while any of them is
191
+ still alive.
192
+
141
193
  ---
142
194
 
143
195
  ## Development
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsQualityAssurance
4
+ # Base class for all errors raised by the gem.
5
+ class Error < StandardError; end
6
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel'
4
+
5
+ module RailsQualityAssurance
6
+ # Resolves the parallel spec worker layout from the environment.
7
+ #
8
+ # `PARALLEL_TEST_PROCESSORS` is the total worker count and defaults to the
9
+ # number of available CPUs. `ISOLATE_SPEC_TASKS` carves that many workers out
10
+ # of the total for `spec/system/`; `0` disables isolation and system specs run
11
+ # as ordinary specs.
12
+ class ParallelSpecConfig
13
+ ISOLATE_PATTERN = 'spec/system/'
14
+ DEFAULT_ISOLATE_PROCESSORS = 1
15
+
16
+ attr_reader :processors, :isolate_processors
17
+
18
+ def self.from_env(env = ENV)
19
+ processors = env.fetch('PARALLEL_TEST_PROCESSORS', Parallel.processor_count).to_i
20
+ isolate = env.fetch('ISOLATE_SPEC_TASKS', default_isolate_processors(processors))
21
+
22
+ new(processors:, isolate_processors: isolate)
23
+ end
24
+
25
+ # A single CPU cannot spare a worker for isolation.
26
+ def self.default_isolate_processors(processors)
27
+ processors > 1 ? DEFAULT_ISOLATE_PROCESSORS : 0
28
+ end
29
+
30
+ def initialize(processors:, isolate_processors:)
31
+ @processors = Integer(processors)
32
+ @isolate_processors = Integer(isolate_processors)
33
+
34
+ validate
35
+ end
36
+
37
+ def isolate?
38
+ isolate_processors.positive?
39
+ end
40
+
41
+ def parallel_options
42
+ return '' unless isolate?
43
+
44
+ "--isolate --single #{ISOLATE_PATTERN} --isolate-n #{isolate_processors}"
45
+ end
46
+
47
+ private
48
+
49
+ def validate
50
+ raise ArgumentError, 'PARALLEL_TEST_PROCESSORS must be a positive integer' if processors <= 0
51
+ raise ArgumentError, 'ISOLATE_SPEC_TASKS must be zero or greater' if isolate_processors.negative?
52
+
53
+ return if isolate_processors < processors
54
+
55
+ raise ArgumentError, 'ISOLATE_SPEC_TASKS must be less than PARALLEL_TEST_PROCESSORS'
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'rails_quality_assurance/error'
7
+
8
+ module RailsQualityAssurance
9
+ # Cross-process exclusive lock that serializes heavy runs such as the
10
+ # parallel spec suite.
11
+ #
12
+ # The lock is held through `flock`, so the kernel releases it when the last
13
+ # holder exits, even on SIGKILL. The holder descriptor is made inheritable so
14
+ # workers spawned by the guarded run keep the lock alive if the coordinating
15
+ # process dies mid-run.
16
+ class RunLock
17
+ # Raised when another run already holds the lock.
18
+ class Busy < Error; end
19
+
20
+ def self.guard(path, command: nil)
21
+ lock = new(path)
22
+ lock.acquire(command)
23
+
24
+ begin
25
+ yield
26
+ ensure
27
+ lock.release
28
+ end
29
+ end
30
+
31
+ def initialize(path)
32
+ @path = path
33
+ @file = nil
34
+ end
35
+
36
+ # Takes the lock and records the holder, or raises Busy with the current
37
+ # holder's details so the caller can tell what is already running.
38
+ def acquire(command = nil)
39
+ @file = open_file
40
+
41
+ return record_holder(command) if try_lock
42
+
43
+ message = busy_message
44
+ release
45
+ raise Busy, message
46
+ end
47
+
48
+ def release
49
+ return unless @file
50
+
51
+ @file.flock(File::LOCK_UN)
52
+ @file.close
53
+ @file = nil
54
+ end
55
+
56
+ private
57
+
58
+ def try_lock
59
+ @file.flock(File::LOCK_EX | File::LOCK_NB)
60
+ rescue Errno::EWOULDBLOCK, Errno::EAGAIN
61
+ false
62
+ end
63
+
64
+ def record_holder(command)
65
+ # Spawned workers must inherit the descriptor, otherwise the lock is
66
+ # released the moment the coordinating process dies while they still run.
67
+ @file.close_on_exec = false
68
+ write_metadata(command)
69
+
70
+ self
71
+ end
72
+
73
+ def open_file
74
+ FileUtils.mkdir_p(File.dirname(@path))
75
+ File.open(@path, File::RDWR | File::CREAT, 0o644)
76
+ end
77
+
78
+ def write_metadata(command)
79
+ @file.rewind
80
+ @file.truncate(0)
81
+ @file.write(JSON.generate('pid' => Process.pid, 'started_at' => Time.now.utc.iso8601, 'command' => command))
82
+ @file.flush
83
+ end
84
+
85
+ def busy_message
86
+ holder = metadata
87
+ return 'Another quality assurance run is already in progress; wait for it to finish.' if holder.empty?
88
+
89
+ "Another quality assurance run is already in progress: #{holder['command']} " \
90
+ "(pid #{holder['pid']}, started #{elapsed(holder['started_at'])} ago). " \
91
+ 'Wait for it to finish before starting another run.'
92
+ end
93
+
94
+ def metadata
95
+ @file.rewind
96
+ JSON.parse(@file.read)
97
+ rescue JSON::ParserError, TypeError
98
+ {}
99
+ end
100
+
101
+ def elapsed(started_at)
102
+ seconds = (Time.now.utc - Time.iso8601(started_at.to_s)).round
103
+ return "#{seconds}s" if seconds < 60
104
+
105
+ minutes = seconds / 60
106
+ return "#{minutes}m#{seconds % 60}s" if minutes < 60
107
+
108
+ "#{minutes / 60}h#{minutes % 60}m"
109
+ rescue ArgumentError, TypeError
110
+ 'an unknown time'
111
+ end
112
+ end
113
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsQualityAssurance
4
- VERSION = '1.4.2'
4
+ VERSION = '1.6.0'
5
5
  end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rails_quality_assurance/error'
3
4
  require 'rails_quality_assurance/version'
4
5
  require 'rails_quality_assurance/ci'
6
+ require 'rails_quality_assurance/parallel_spec_config'
7
+ require 'rails_quality_assurance/run_lock'
5
8
 
6
9
  module RailsQualityAssurance
7
- class Error < StandardError; end
8
-
9
10
  def self.root
10
11
  File.expand_path('..', __dir__)
11
12
  end
@@ -33,6 +34,11 @@ module RailsQualityAssurance
33
34
  'bundle exec ruby -e \'require "importmap-rails"; require "importmap/map"; ' \
34
35
  'ARGV.replace(["audit"]); require "importmap/commands"\''
35
36
  end
37
+
38
+ # Shared lock file that serializes parallel spec runs for the current app.
39
+ def self.spec_lock_path
40
+ File.join('tmp', 'qa', 'spec_parallel.lock')
41
+ end
36
42
  end
37
43
 
38
44
  require 'rails_quality_assurance/railtie'
@@ -89,18 +89,18 @@ namespace :spec do
89
89
  desc 'Run specs in parallel with system specs isolated'
90
90
  task parallel: :environment do
91
91
  ENV['PARALLEL_TEST_FIRST_IS_1'] ||= 'true'
92
- parallel_tasks = ENV.fetch('PARALLEL_TEST_PROCESSORS', 3).to_s
93
- isolate_tasks = ENV.fetch('ISOLATE_SPEC_TASKS', 1).to_s
92
+ config = RailsQualityAssurance::ParallelSpecConfig.from_env
94
93
  test_options = ENV.fetch('PARALLEL_SPEC_OPTIONS', nil)
95
- raise ArgumentError, 'PARALLEL_TEST_PROCESSORS must be a positive integer' if parallel_tasks.to_i <= 0
96
94
 
97
- options = "--isolate --single spec/system/ --isolate-n #{isolate_tasks}"
98
- options << " --test-options=\"#{test_options}\"" if test_options
95
+ options = config.parallel_options
96
+ options = "#{options} --test-options=\"#{test_options}\"" if test_options
99
97
 
100
- # Each phase runs in its own rake process: parallel_tests shells out to `$0`
101
- # to load a schema per worker, which only works when `$0` is a rake runner.
102
- sh "bundle exec rake parallel:create[#{parallel_tasks}]"
103
- sh "bundle exec rake parallel:load_schema[#{parallel_tasks}]"
104
- sh "bundle exec parallel_rspec spec/ -n #{parallel_tasks} #{options}"
98
+ RailsQualityAssurance::RunLock.guard(RailsQualityAssurance.spec_lock_path, command: 'spec:parallel') do
99
+ # Each phase runs in its own rake process: parallel_tests shells out to `$0`
100
+ # to load a schema per worker, which only works when `$0` is a rake runner.
101
+ sh "bundle exec rake parallel:create[#{config.processors}]"
102
+ sh "bundle exec rake parallel:load_schema[#{config.processors}]"
103
+ sh "bundle exec parallel_rspec spec/ -n #{config.processors} #{options}"
104
+ end
105
105
  end
106
106
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-quality-assurance
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mauricio Zaffari
@@ -395,6 +395,8 @@ files:
395
395
  - lib/rails_quality_assurance/all.rb
396
396
  - lib/rails_quality_assurance/ci.rb
397
397
  - lib/rails_quality_assurance/ci_runner.rb
398
+ - lib/rails_quality_assurance/error.rb
399
+ - lib/rails_quality_assurance/parallel_spec_config.rb
398
400
  - lib/rails_quality_assurance/playwright.rb
399
401
  - lib/rails_quality_assurance/playwright/helper.rb
400
402
  - lib/rails_quality_assurance/rails/helper.rb
@@ -403,6 +405,7 @@ files:
403
405
  - lib/rails_quality_assurance/railtie.rb
404
406
  - lib/rails_quality_assurance/rspec.rb
405
407
  - lib/rails_quality_assurance/rspec/helper.rb
408
+ - lib/rails_quality_assurance/run_lock.rb
406
409
  - lib/rails_quality_assurance/simplecov.rb
407
410
  - lib/rails_quality_assurance/simplecov/helper.rb
408
411
  - lib/rails_quality_assurance/test_helper.rb