flaky-friend 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e3410a7b5cc61c235701fe0eba23923ffdafe5d46ce95ab2065b8218f35e3ce2
4
+ data.tar.gz: e419f801b44b0bc80eb5af88b4da5bf2014b6731f1c3f2b0c550b59698ae7389
5
+ SHA512:
6
+ metadata.gz: bee24490b01ced67fb5ba16addb18f390c0e03b08946fa1309b5ae64e33d961895741e3c59abdf7cd84c0991791762ede207ce94db895acbbf5f9b5d4ade6a9b
7
+ data.tar.gz: da12cc1017c510ca264e89b583fa59577c1823d2ab3c8c87b4362f906f38f3c99b48d20d3660fddf786ad97c2ce3b618e5e03326b4d9010e932cfbb052a0ebe5
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flytedesk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Flaky
2
+
3
+ Track, rank, and reproduce flaky CI test failures in Rails projects.
4
+
5
+ Flaky fetches test results from your CI provider, stores failures in a local SQLite database, ranks tests by flakiness, and helps reproduce failures under simulated CI conditions.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ # From GitHub
13
+ gem 'flaky', github: 'Flytedesk/flaky', group: [:development, :test]
14
+
15
+ # Or from a local path during development
16
+ gem 'flaky', path: '../flaky', group: [:development, :test]
17
+ ```
18
+
19
+ Then `bundle install`.
20
+
21
+ ## Configuration
22
+
23
+ Create an initializer (e.g. `config/initializers/flaky.rb`):
24
+
25
+ ```ruby
26
+ if defined?(Flaky)
27
+ Flaky.configure do |c|
28
+ c.provider = :semaphore # or :github_actions
29
+ c.project = "my-project" # CI project name
30
+ c.branch = "main" # branch to track
31
+ end
32
+ end
33
+ ```
34
+
35
+ ### Prerequisites by provider
36
+
37
+ **Semaphore**: Install and authenticate the [`sem` CLI](https://docs.semaphoreci.com/reference/sem-command-line-tool/).
38
+
39
+ **GitHub Actions**: Install and authenticate the [`gh` CLI](https://cli.github.com/).
40
+
41
+ ## Rake Tasks
42
+
43
+ ### `rake flaky:fetch[age]`
44
+
45
+ Fetch recent CI results and store failures in the local database.
46
+
47
+ ```sh
48
+ rake flaky:fetch # last 24 hours (default)
49
+ rake flaky:fetch[168h] # last 7 days
50
+ rake flaky:fetch[2160h] # last 90 days
51
+ ```
52
+
53
+ For each workflow on the configured branch, fetches all test job logs, parses RSpec output for failures and random seeds, and inserts new records into `tmp/flaky.db`.
54
+
55
+ ### `rake flaky:rank[since]`
56
+
57
+ Rank flaky tests by failure frequency and suggest the next one to investigate.
58
+
59
+ ```sh
60
+ rake flaky:rank # last 30 days (default)
61
+ rake flaky:rank[7] # last 7 days
62
+ ```
63
+
64
+ Output:
65
+
66
+ ```
67
+ Flaky tests on main (last 30 days, 42 CI runs):
68
+
69
+ Fails Location Last Failure
70
+ ------------------------------------------------------------------------------------------
71
+ 5 ...spec/system/inventory_search_modal_spec.rb:83 2026-04-12 09:15:22
72
+
73
+ > Next to investigate: packs/.../inventory_search_modal_spec.rb:83
74
+ Inventory search modal filters by enrollment
75
+ Seeds: 6432, 51203, 8891
76
+ ```
77
+
78
+ ### `rake flaky:history[spec_location]`
79
+
80
+ Show the full failure timeline for a specific test, including every seed and CI job it failed in.
81
+
82
+ ```sh
83
+ rake flaky:history[inventory_search_modal_spec.rb:83]
84
+ rake flaky:history[inventory_search_modal_spec.rb] # all failures in this file
85
+ ```
86
+
87
+ ### `rake flaky:stress[spec,iterations,seed,ci]`
88
+
89
+ Run a test repeatedly to reproduce a flaky failure or prove a fix is stable.
90
+
91
+ ```sh
92
+ # 20 iterations with random seeds
93
+ rake flaky:stress[path/to/spec.rb:83]
94
+
95
+ # 50 iterations with a specific seed and CI simulation
96
+ rake flaky:stress[path/to/spec.rb:83,50,6432,true]
97
+ ```
98
+
99
+ Arguments:
100
+ - `spec` (required) -- spec file path, optionally with line number
101
+ - `iterations` -- number of runs (default: 20)
102
+ - `seed` -- RSpec random seed; omit for random each run
103
+ - `ci` -- `true` to enable CI environment simulation (default: false)
104
+
105
+ Results are recorded to the database and shown in `rake flaky:report`.
106
+
107
+ ### `rake flaky:report`
108
+
109
+ Summary dashboard showing overall flaky test health.
110
+
111
+ ```sh
112
+ rake flaky:report
113
+ ```
114
+
115
+ Output:
116
+
117
+ ```
118
+ === Flaky Test Report (main) ===
119
+
120
+ CI Runs tracked: 42
121
+ Failed runs: 8 (19.0%)
122
+ Total test failures: 14
123
+ Unique flaky specs: 6
124
+ Last fetch: 2026-04-14 20:39:57
125
+
126
+ 7-day trend: 3 failures (prior 7 days: 5)
127
+ v Trending better
128
+
129
+ Top 5 flaky tests:
130
+ --------------------------------------------------------------------------------
131
+ 1. packs/.../inventory_search_modal_spec.rb:83 (5x)
132
+ Inventory search modal filters by enrollment
133
+
134
+ Recent stress runs:
135
+ --------------------------------------------------------------------------------
136
+ packs/.../inventory_search_modal_spec.rb:83 -- 18/20 passed (10.0% failure rate) [CI sim]
137
+ ```
138
+
139
+ ## CI Simulation
140
+
141
+ When `ci=true` is passed to `rake flaky:stress`, the gem simulates CI environment constraints:
142
+
143
+ 1. **Rack middleware latency** -- adds 30ms delay per HTTP request (approximates the difference between a Mac and an f1-standard-2 CI machine). Configurable via `FLAKY_LATENCY_MS` env var.
144
+
145
+ 2. **Reduced Puma threads** -- the host app should conditionally reduce Capybara's Puma threads when `FLAKY_CI_SIMULATE=1` is set:
146
+
147
+ ```ruby
148
+ # spec/support/capybara_drivers.rb (or equivalent)
149
+ max_threads = ENV["FLAKY_CI_SIMULATE"] ? 2 : 8
150
+ Capybara.server = :puma, { Silent: true, Threads: "1:#{max_threads}" }
151
+ ```
152
+
153
+ The middleware is auto-inserted by the Railtie in test environment when `FLAKY_CI_SIMULATE=1`.
154
+
155
+ ## Database
156
+
157
+ Failures are stored in SQLite at `tmp/flaky.db` (auto-created on first use). The schema is managed internally and migrated automatically.
158
+
159
+ Tables:
160
+ - `ci_runs` -- one row per CI workflow on the tracked branch
161
+ - `job_results` -- one row per test job (unit tests, system tests, etc.)
162
+ - `test_failures` -- one row per individual test failure with spec file, line, description, and seed
163
+ - `stress_runs` -- one row per stress test session
164
+
165
+ The database is local and should be gitignored (typically already is via `tmp/`).
166
+
167
+ ## Custom Providers
168
+
169
+ To add a CI provider, implement the three-method interface and register it:
170
+
171
+ ```ruby
172
+ class Flaky::Providers::CircleCI < Flaky::Providers::Base
173
+ def fetch_workflows(age: "24h")
174
+ # Return [{ id:, pipeline_id:, branch:, created_at: }, ...]
175
+ end
176
+
177
+ def fetch_jobs(pipeline_id:)
178
+ # Return [{ id:, name:, block_name:, result: }, ...]
179
+ end
180
+
181
+ def fetch_log(job_id:)
182
+ # Return raw log string
183
+ end
184
+ end
185
+
186
+ Flaky.register_provider(:circleci, Flaky::Providers::CircleCI)
187
+ ```
188
+
189
+ The log parser is CI-agnostic -- it extracts failures, seeds, and counts from standard RSpec output. Your provider just needs to return the raw log text.
190
+
191
+ ## Typical Workflow
192
+
193
+ ```sh
194
+ # 1. Fetch recent CI data
195
+ rake flaky:fetch[168h]
196
+
197
+ # 2. See what's flaky
198
+ rake flaky:rank
199
+
200
+ # 3. Investigate the top offender
201
+ rake flaky:history[the_flaky_spec.rb:42]
202
+
203
+ # 4. Try to reproduce it locally with CI simulation
204
+ rake flaky:stress[the_flaky_spec.rb:42,30,6432,true]
205
+
206
+ # 5. Fix the test, then prove the fix holds
207
+ rake flaky:stress[the_flaky_spec.rb:42,50,,true]
208
+
209
+ # 6. Check overall health
210
+ rake flaky:report
211
+ ```
212
+
213
+ ## License
214
+
215
+ MIT
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database"
4
+ require_relative "../log_parser"
5
+
6
+ module Flaky
7
+ module Commands
8
+ class Fetch
9
+ def initialize(age: "24h")
10
+ @age = age
11
+ @db = Database.new
12
+ @parser = LogParser.new
13
+ end
14
+
15
+ def execute
16
+ provider = Flaky.provider
17
+ branch = Flaky.configuration.branch
18
+ conn = @db.connection
19
+
20
+ workflows = provider.fetch_workflows(age: @age)
21
+ main_workflows = workflows.select { |w| w[:branch] == branch }
22
+
23
+ if main_workflows.empty?
24
+ puts "No #{branch} workflows found in the last #{@age}."
25
+ return
26
+ end
27
+
28
+ new_workflows = 0
29
+ new_failures = 0
30
+ total_jobs = 0
31
+
32
+ main_workflows.each do |wf|
33
+ # Skip if already fetched
34
+ existing = conn.get_first_value("SELECT 1 FROM ci_runs WHERE workflow_id = ?", wf[:id])
35
+ next if existing
36
+
37
+ # Determine pipeline result by fetching jobs
38
+ jobs = provider.fetch_jobs(pipeline_id: wf[:pipeline_id])
39
+ pipeline_result = jobs.any? { |j| j[:result] == "failed" } ? "failed" : "passed"
40
+
41
+ conn.execute(
42
+ "INSERT INTO ci_runs (workflow_id, pipeline_id, branch, result, created_at) VALUES (?, ?, ?, ?, ?)",
43
+ [wf[:id], wf[:pipeline_id], wf[:branch], pipeline_result, wf[:created_at]]
44
+ )
45
+ new_workflows += 1
46
+
47
+ jobs.each do |job|
48
+ total_jobs += 1
49
+ log = provider.fetch_log(job_id: job[:id])
50
+ parsed = @parser.parse(log)
51
+
52
+ conn.execute(
53
+ "INSERT OR IGNORE INTO job_results (job_id, workflow_id, job_name, block_name, result, example_count, failure_count, seed, duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
54
+ [job[:id], wf[:id], job[:name], job[:block_name], parsed.failure_count.to_i > 0 ? "failed" : "passed",
55
+ parsed.example_count, parsed.failure_count, parsed.seed, parsed.duration_seconds]
56
+ )
57
+
58
+ parsed.failures.each do |failure|
59
+ conn.execute(
60
+ "INSERT OR IGNORE INTO test_failures (workflow_id, job_id, job_name, spec_file, line_number, description, seed, branch, failed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
61
+ [wf[:id], job[:id], job[:name], failure.spec_file, failure.line_number, failure.description,
62
+ parsed.seed, wf[:branch], wf[:created_at]]
63
+ )
64
+ new_failures += 1
65
+ end
66
+ end
67
+ end
68
+
69
+ puts "Fetched #{new_workflows} new workflow(s), #{total_jobs} job(s) parsed, #{new_failures} failure(s) recorded."
70
+ ensure
71
+ @db.close
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database"
4
+
5
+ module Flaky
6
+ module Commands
7
+ class History
8
+ def initialize(spec_location:)
9
+ @spec_location = spec_location
10
+ @db = Database.new
11
+ end
12
+
13
+ def execute
14
+ conn = @db.connection
15
+
16
+ file, line = parse_location(@spec_location)
17
+
18
+ conditions = ["tf.spec_file LIKE ?"]
19
+ params = ["%#{file}%"]
20
+
21
+ if line
22
+ conditions << "tf.line_number = ?"
23
+ params << line
24
+ end
25
+
26
+ rows = conn.execute(<<~SQL, params)
27
+ SELECT
28
+ tf.spec_file,
29
+ tf.line_number,
30
+ tf.description,
31
+ tf.seed,
32
+ tf.job_name,
33
+ tf.branch,
34
+ tf.failed_at,
35
+ cr.workflow_id
36
+ FROM test_failures tf
37
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
38
+ WHERE #{conditions.join(" AND ")}
39
+ ORDER BY tf.failed_at DESC
40
+ SQL
41
+
42
+ if rows.empty?
43
+ puts "No failures found matching '#{@spec_location}'."
44
+ return
45
+ end
46
+
47
+ first = rows.first
48
+ puts "Failure history for #{first['spec_file']}:#{first['line_number']}"
49
+ puts " #{first['description']}\n\n"
50
+ puts format("%-20s %-8s %-30s %s", "Date", "Seed", "Job", "Workflow")
51
+ puts "-" * 100
52
+
53
+ rows.each do |row|
54
+ puts format("%-20s %-8d %-30s %s", row["failed_at"], row["seed"], row["job_name"], row["workflow_id"])
55
+ end
56
+
57
+ puts "\nTotal failures: #{rows.length}"
58
+
59
+ seeds = rows.map { |r| r["seed"] }.uniq
60
+ puts "Unique seeds: #{seeds.join(', ')}"
61
+ puts "\nTo reproduce: rake flaky:stress[#{first['spec_file']}:#{first['line_number']},10,#{seeds.first},true]"
62
+ ensure
63
+ @db.close
64
+ end
65
+
66
+ private
67
+
68
+ def parse_location(loc)
69
+ if loc.include?(":")
70
+ parts = loc.rpartition(":")
71
+ [parts[0], parts[2].to_i]
72
+ else
73
+ [loc, nil]
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database"
4
+
5
+ module Flaky
6
+ module Commands
7
+ class Rank
8
+ def initialize(since_days: 30, min_failures: 1)
9
+ @since_days = since_days
10
+ @min_failures = min_failures
11
+ @db = Database.new
12
+ end
13
+
14
+ def execute
15
+ conn = @db.connection
16
+ branch = Flaky.configuration.branch
17
+
18
+ rows = conn.execute(<<~SQL, [branch, "-#{@since_days} days", @min_failures])
19
+ SELECT
20
+ tf.spec_file,
21
+ tf.line_number,
22
+ tf.description,
23
+ COUNT(*) as failure_count,
24
+ MAX(tf.failed_at) as last_failure,
25
+ GROUP_CONCAT(DISTINCT tf.seed) as seeds
26
+ FROM test_failures tf
27
+ JOIN ci_runs cr ON cr.workflow_id = tf.workflow_id
28
+ WHERE cr.branch = ?
29
+ AND cr.created_at >= datetime('now', ?)
30
+ GROUP BY tf.spec_file, tf.line_number
31
+ HAVING COUNT(*) >= ?
32
+ ORDER BY failure_count DESC, last_failure DESC
33
+ SQL
34
+
35
+ if rows.empty?
36
+ puts "No flaky tests found in the last #{@since_days} days."
37
+ return
38
+ end
39
+
40
+ total_runs = conn.get_first_value(
41
+ "SELECT COUNT(DISTINCT workflow_id) FROM ci_runs WHERE branch = ? AND created_at >= datetime('now', ?)",
42
+ [branch, "-#{@since_days} days"]
43
+ ).to_i
44
+
45
+ puts "Flaky tests on #{branch} (last #{@since_days} days, #{total_runs} CI runs):\n\n"
46
+ puts format("%-6s %-50s %s", "Fails", "Location", "Last Failure")
47
+ puts "-" * 90
48
+
49
+ rows.each_with_index do |row, i|
50
+ location = "#{row['spec_file']}:#{row['line_number']}"
51
+ truncated = location.length > 48 ? "...#{location[-45..]}" : location
52
+ puts format("%-6d %-50s %s", row["failure_count"], truncated, row["last_failure"])
53
+
54
+ if i == 0
55
+ puts "\n \e[33m▶ Next to investigate:\e[0m #{location}"
56
+ puts " #{row['description']}"
57
+ puts " Seeds: #{row['seeds']}"
58
+ puts ""
59
+ end
60
+ end
61
+ ensure
62
+ @db.close
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database"
4
+
5
+ module Flaky
6
+ module Commands
7
+ class Report
8
+ def initialize
9
+ @db = Database.new
10
+ end
11
+
12
+ def execute
13
+ conn = @db.connection
14
+ branch = Flaky.configuration.branch
15
+
16
+ total_runs = conn.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ?", branch).to_i
17
+ failed_runs = conn.get_first_value("SELECT COUNT(*) FROM ci_runs WHERE branch = ? AND result = 'failed'", branch).to_i
18
+ total_failures = conn.get_first_value("SELECT COUNT(*) FROM test_failures WHERE branch = ?", branch).to_i
19
+ unique_specs = conn.get_first_value("SELECT COUNT(DISTINCT spec_file || ':' || line_number) FROM test_failures WHERE branch = ?", branch).to_i
20
+ last_fetch = conn.get_first_value("SELECT MAX(fetched_at) FROM ci_runs")
21
+
22
+ puts "=== Flaky Test Report (#{branch}) ==="
23
+ puts ""
24
+ puts "CI Runs tracked: #{total_runs}"
25
+ puts "Failed runs: #{failed_runs} (#{total_runs > 0 ? (failed_runs.to_f / total_runs * 100).round(1) : 0}%)"
26
+ puts "Total test failures: #{total_failures}"
27
+ puts "Unique flaky specs: #{unique_specs}"
28
+ puts "Last fetch: #{last_fetch || 'never'}"
29
+
30
+ # Recent trend
31
+ recent = conn.get_first_value(
32
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', '-7 days')", branch
33
+ ).to_i
34
+ prior = conn.get_first_value(
35
+ "SELECT COUNT(*) FROM test_failures WHERE branch = ? AND failed_at >= datetime('now', '-14 days') AND failed_at < datetime('now', '-7 days')", branch
36
+ ).to_i
37
+
38
+ puts ""
39
+ puts "7-day trend: #{recent} failures (prior 7 days: #{prior})"
40
+
41
+ if recent > prior
42
+ puts " \e[31m▲ Trending worse\e[0m"
43
+ elsif recent < prior
44
+ puts " \e[32m▼ Trending better\e[0m"
45
+ else
46
+ puts " → Stable"
47
+ end
48
+
49
+ # Top 5 flaky tests
50
+ top = conn.execute(<<~SQL, [branch])
51
+ SELECT
52
+ spec_file,
53
+ line_number,
54
+ description,
55
+ COUNT(*) as failure_count,
56
+ MAX(failed_at) as last_failure
57
+ FROM test_failures
58
+ WHERE branch = ?
59
+ GROUP BY spec_file, line_number
60
+ ORDER BY failure_count DESC
61
+ LIMIT 5
62
+ SQL
63
+
64
+ if top.any?
65
+ puts "\nTop 5 flaky tests:"
66
+ puts "-" * 80
67
+ top.each_with_index do |row, i|
68
+ puts " #{i + 1}. #{row['spec_file']}:#{row['line_number']} (#{row['failure_count']}x)"
69
+ puts " #{row['description']}"
70
+ end
71
+ end
72
+
73
+ # Recent stress runs
74
+ stress = conn.execute("SELECT * FROM stress_runs ORDER BY created_at DESC LIMIT 3")
75
+ if stress.any?
76
+ puts "\nRecent stress runs:"
77
+ puts "-" * 80
78
+ stress.each do |run|
79
+ total = run["passes"] + run["failures"]
80
+ rate = total > 0 ? (run["failures"].to_f / total * 100).round(1) : 0
81
+ ci_flag = run["ci_simulation"] == 1 ? " [CI sim]" : ""
82
+ puts " #{run['spec_location']} — #{run['passes']}/#{total} passed (#{rate}% failure rate)#{ci_flag}"
83
+ end
84
+ end
85
+ ensure
86
+ @db.close
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database"
4
+
5
+ module Flaky
6
+ module Commands
7
+ class Stress
8
+ def initialize(spec_location:, iterations: 20, seed: nil, ci_simulate: false, timeout: 600)
9
+ @spec_location = spec_location
10
+ @iterations = iterations
11
+ @seed = seed
12
+ @ci_simulate = ci_simulate
13
+ @timeout = timeout
14
+ @db = Database.new
15
+ end
16
+
17
+ def execute
18
+ env = {}
19
+ env["FLAKY_CI_SIMULATE"] = "1" if @ci_simulate
20
+
21
+ passes = 0
22
+ failures = 0
23
+ failed_seeds = []
24
+ start_time = Time.now
25
+
26
+ puts "Stress testing: #{@spec_location}"
27
+ puts " Iterations: #{@iterations}, Seed: #{@seed || 'random'}, CI simulation: #{@ci_simulate}"
28
+ puts ""
29
+
30
+ @iterations.times do |i|
31
+ elapsed = Time.now - start_time
32
+ if elapsed > @timeout
33
+ puts "\n\nTimeout reached (#{@timeout}s) after #{i} iterations."
34
+ break
35
+ end
36
+
37
+ run_seed = @seed || rand(100_000)
38
+ cmd = "bundle exec rspec #{@spec_location} --seed #{run_seed} --format progress 2>&1"
39
+
40
+ output = nil
41
+ success = nil
42
+ IO.popen(env, cmd) do |io|
43
+ output = io.read
44
+ io.close
45
+ success = $?.success?
46
+ end
47
+
48
+ if success
49
+ passes += 1
50
+ print "\e[32m.\e[0m"
51
+ else
52
+ failures += 1
53
+ failed_seeds << run_seed
54
+ print "\e[31mF\e[0m"
55
+ end
56
+ end
57
+
58
+ total = passes + failures
59
+ rate = total > 0 ? (failures.to_f / total * 100).round(1) : 0
60
+
61
+ puts "\n\n#{total} runs: #{passes} passed, #{failures} failed"
62
+ puts "Failure rate: #{rate}%"
63
+
64
+ if failed_seeds.any?
65
+ puts "Failed seeds: #{failed_seeds.join(', ')}"
66
+ puts "\nTo reproduce a specific failure:"
67
+ puts " FLAKY_CI_SIMULATE=#{@ci_simulate ? '1' : '0'} bundle exec rspec #{@spec_location} --seed #{failed_seeds.first}"
68
+ end
69
+
70
+ # Record to database
71
+ conn = @db.connection
72
+ conn.execute(
73
+ "INSERT INTO stress_runs (spec_location, seed, iterations, passes, failures, ci_simulation) VALUES (?, ?, ?, ?, ?, ?)",
74
+ [@spec_location, @seed, @iterations, passes, failures, @ci_simulate ? 1 : 0]
75
+ )
76
+
77
+ exit(failures > 0 ? 1 : 0)
78
+ ensure
79
+ @db.close
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ class Configuration
5
+ PROVIDERS = {}
6
+
7
+ attr_accessor :project, :branch, :db_path
8
+
9
+ def initialize
10
+ @provider_name = nil
11
+ @project = nil
12
+ @branch = "main"
13
+ @db_path = nil # resolved lazily
14
+ end
15
+
16
+ def provider=(name)
17
+ @provider_name = name.to_sym
18
+ end
19
+
20
+ def provider_instance
21
+ klass = PROVIDERS[@provider_name] || raise(Error, "Unknown provider: #{@provider_name}. Registered: #{PROVIDERS.keys.join(', ')}")
22
+ klass.new(self)
23
+ end
24
+
25
+ def resolved_db_path
26
+ @db_path || (defined?(Rails) ? Rails.root.join("tmp", "flaky.db").to_s : "tmp/flaky.db")
27
+ end
28
+
29
+ def self.register_provider(name, klass)
30
+ PROVIDERS[name.to_sym] = klass
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+
5
+ module Flaky
6
+ class Database
7
+ SCHEMA_VERSION = 1
8
+
9
+ def initialize(path = nil)
10
+ @path = path || Flaky.configuration.resolved_db_path
11
+ end
12
+
13
+ def connection
14
+ @connection ||= begin
15
+ dir = File.dirname(@path)
16
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
17
+ db = SQLite3::Database.new(@path)
18
+ db.results_as_hash = true
19
+ db.execute("PRAGMA journal_mode=WAL")
20
+ db.execute("PRAGMA foreign_keys=ON")
21
+ migrate!(db)
22
+ db
23
+ end
24
+ end
25
+
26
+ def close
27
+ @connection&.close
28
+ @connection = nil
29
+ end
30
+
31
+ private
32
+
33
+ def migrate!(db)
34
+ version = db.get_first_value("PRAGMA user_version").to_i
35
+
36
+ if version < 1
37
+ db.execute_batch(<<~SQL)
38
+ CREATE TABLE IF NOT EXISTS ci_runs (
39
+ workflow_id TEXT PRIMARY KEY,
40
+ pipeline_id TEXT NOT NULL,
41
+ branch TEXT NOT NULL,
42
+ result TEXT NOT NULL,
43
+ created_at TEXT NOT NULL,
44
+ fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS job_results (
48
+ job_id TEXT PRIMARY KEY,
49
+ workflow_id TEXT NOT NULL REFERENCES ci_runs(workflow_id),
50
+ job_name TEXT NOT NULL,
51
+ block_name TEXT NOT NULL,
52
+ result TEXT NOT NULL,
53
+ example_count INTEGER,
54
+ failure_count INTEGER,
55
+ seed INTEGER,
56
+ duration_seconds REAL,
57
+ fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS test_failures (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ workflow_id TEXT NOT NULL REFERENCES ci_runs(workflow_id),
63
+ job_id TEXT NOT NULL REFERENCES job_results(job_id),
64
+ job_name TEXT NOT NULL,
65
+ spec_file TEXT NOT NULL,
66
+ line_number INTEGER NOT NULL,
67
+ description TEXT NOT NULL,
68
+ seed INTEGER NOT NULL,
69
+ branch TEXT NOT NULL,
70
+ failed_at TEXT NOT NULL,
71
+ UNIQUE(workflow_id, spec_file, line_number)
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS stress_runs (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ spec_location TEXT NOT NULL,
77
+ seed INTEGER,
78
+ iterations INTEGER NOT NULL,
79
+ passes INTEGER NOT NULL DEFAULT 0,
80
+ failures INTEGER NOT NULL DEFAULT 0,
81
+ ci_simulation INTEGER NOT NULL DEFAULT 0,
82
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_test_failures_spec ON test_failures(spec_file, line_number);
86
+ CREATE INDEX IF NOT EXISTS idx_test_failures_branch ON test_failures(branch);
87
+ CREATE INDEX IF NOT EXISTS idx_ci_runs_branch ON ci_runs(branch);
88
+ CREATE INDEX IF NOT EXISTS idx_job_results_workflow ON job_results(workflow_id);
89
+
90
+ PRAGMA user_version = 1;
91
+ SQL
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ class LogParser
5
+ Result = Data.define(:seed, :example_count, :failure_count, :duration_seconds, :failures)
6
+ Failure = Data.define(:spec_file, :line_number, :description)
7
+
8
+ def parse(raw_log)
9
+ log = normalize(raw_log)
10
+
11
+ Result.new(
12
+ seed: extract_seed(log),
13
+ example_count: extract_example_count(log),
14
+ failure_count: extract_failure_count(log),
15
+ duration_seconds: extract_duration(log),
16
+ failures: extract_failures(log)
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ def normalize(text)
23
+ # 1. Strip ANSI escape codes
24
+ clean = text.gsub(/\e\[\d*;?\d*m/, "")
25
+ # 2. Rejoin lines broken by Semaphore's ~80-char wrapping.
26
+ # Real RSpec blank lines (section separators) are preserved.
27
+ # A wrapped line is one where the previous line doesn't end with
28
+ # a logical boundary (blank line, "exit status:", prompt markers).
29
+ clean
30
+ end
31
+
32
+ def extract_seed(log)
33
+ log.scan(/Randomized with seed (\d+)/).flatten.last&.to_i
34
+ end
35
+
36
+ def extract_example_count(log)
37
+ # Allow whitespace (including newlines from wrapping) between number and "examples"
38
+ log.scan(/(\d+)\s+examples?/).flatten.last&.to_i
39
+ end
40
+
41
+ def extract_failure_count(log)
42
+ # Match "N failure(s)" allowing wrapped newlines; take the last occurrence
43
+ log.scan(/(\d+)\s+failures?/).flatten.last&.to_i || 0
44
+ end
45
+
46
+ def extract_duration(log)
47
+ # "Finished in N minutes N.N seconds" — may span wrapped lines
48
+ # Collapse the area around "Finished in" first
49
+ section = log[/Finished in.{0,80}/m]
50
+ return nil unless section
51
+
52
+ collapsed = section.gsub(/\s+/, " ")
53
+ match = collapsed.match(/Finished in\s+((\d+)\s+minutes?\s+)?(\d+(?:\.\d+)?)\s+seconds?/)
54
+ return nil unless match
55
+
56
+ seconds = match[3].to_f
57
+ seconds += match[2].to_i * 60 if match[2]
58
+ seconds
59
+ end
60
+
61
+ def extract_failures(log)
62
+ # Find section between "Failed examples:" and "Randomized with seed"
63
+ section = log[/Failed examples:\s*\n(.*?)(?=\nRandomized with seed)/m, 1]
64
+ return [] unless section
65
+
66
+ # Semaphore wraps lines at ~80 chars, splitting mid-word.
67
+ # Remove newlines directly (not replacing with space) to rejoin split tokens.
68
+ collapsed = section.delete("\n").squeeze(" ").strip
69
+
70
+ collapsed.scan(/rspec\s+\.\/(\S+):(\d+)\s+#\s+(.*?)(?=\s*rspec\s+\.\/|\s*$)/).map do |file, line, desc|
71
+ Failure.new(spec_file: file, line_number: line.to_i, description: desc.strip)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ module Middleware
5
+ class SimulateCiLatency
6
+ # f1-standard-2: 2 vCPU, 4 GB RAM (shared tenant)
7
+ # Local Mac: 10+ cores, 16-64 GB RAM (dedicated)
8
+ # Empirically, CI system tests take ~2x longer than local.
9
+ # A 30ms delay per request approximates the difference.
10
+ DEFAULT_DELAY_MS = 30
11
+
12
+ def initialize(app, delay_ms: nil)
13
+ @app = app
14
+ @delay_ms = (delay_ms || ENV.fetch("FLAKY_LATENCY_MS", DEFAULT_DELAY_MS)).to_f
15
+ end
16
+
17
+ def call(env)
18
+ sleep(@delay_ms / 1000.0) if @delay_ms > 0
19
+ @app.call(env)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ module Providers
5
+ class Base
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ # Returns Array of Hashes:
13
+ # { id:, pipeline_id:, branch:, result:, created_at: }
14
+ def fetch_workflows(age: "24h")
15
+ raise NotImplementedError
16
+ end
17
+
18
+ # Returns Array of Hashes:
19
+ # { id:, name:, block_name:, result: }
20
+ def fetch_jobs(pipeline_id:)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Returns raw log String for a job
25
+ def fetch_log(job_id:)
26
+ raise NotImplementedError
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Flaky
7
+ module Providers
8
+ class GithubActions < Base
9
+ def fetch_workflows(age: "24h")
10
+ hours = parse_age_to_hours(age)
11
+ cutoff = Time.now - (hours * 3600)
12
+
13
+ output = run_cmd("gh run list --branch #{config.branch} --limit 100 --json databaseId,conclusion,createdAt,headBranch,workflowName")
14
+ runs = JSON.parse(output)
15
+
16
+ runs.filter_map do |run|
17
+ created = Time.parse(run["createdAt"])
18
+ next if created < cutoff
19
+
20
+ {
21
+ id: run["databaseId"].to_s,
22
+ pipeline_id: run["databaseId"].to_s,
23
+ branch: run["headBranch"],
24
+ result: map_conclusion(run["conclusion"]),
25
+ created_at: run["createdAt"]
26
+ }
27
+ end
28
+ end
29
+
30
+ def fetch_jobs(pipeline_id:)
31
+ output = run_cmd("gh run view #{pipeline_id} --json jobs")
32
+ data = JSON.parse(output)
33
+
34
+ (data["jobs"] || []).filter_map do |job|
35
+ name = job["name"]
36
+ next unless name.match?(/test/i)
37
+
38
+ {
39
+ id: job["databaseId"].to_s,
40
+ name: name,
41
+ block_name: name,
42
+ result: map_conclusion(job["conclusion"])
43
+ }
44
+ end
45
+ end
46
+
47
+ def fetch_log(job_id:)
48
+ run_cmd("gh run view --job #{job_id} --log")
49
+ end
50
+
51
+ private
52
+
53
+ def run_cmd(cmd)
54
+ output = `#{cmd} 2>/dev/null`
55
+ raise Error, "Command failed (exit #{$?.exitstatus}): #{cmd}" unless $?.success?
56
+ output
57
+ end
58
+
59
+ def parse_age_to_hours(age)
60
+ case age
61
+ when /(\d+)h/ then $1.to_i
62
+ when /(\d+)d/ then $1.to_i * 24
63
+ else 24
64
+ end
65
+ end
66
+
67
+ def map_conclusion(conclusion)
68
+ case conclusion
69
+ when "success" then "passed"
70
+ when "failure" then "failed"
71
+ else conclusion || "unknown"
72
+ end
73
+ end
74
+ end
75
+
76
+ Configuration.register_provider(:github_actions, GithubActions)
77
+ end
78
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "base"
5
+
6
+ module Flaky
7
+ module Providers
8
+ class Semaphore < Base
9
+ TEST_BLOCKS = ["Unit Tests", "System Tests"].freeze
10
+
11
+ def fetch_workflows(age: "24h")
12
+ output = run_cmd("sem get workflows -p #{config.project} --age #{age}")
13
+ lines = output.lines.drop(1) # skip header
14
+ lines.filter_map { |line| parse_workflow_line(line) }
15
+ end
16
+
17
+ def fetch_jobs(pipeline_id:)
18
+ output = run_cmd("sem get pipelines #{pipeline_id}")
19
+ data = YAML.safe_load(output, permitted_classes: [Date, Time])
20
+ blocks = data&.[]("blocks")
21
+ return [] unless blocks
22
+
23
+ blocks.flat_map do |block|
24
+ block_name = block["name"]
25
+ next [] unless TEST_BLOCKS.include?(block_name)
26
+
27
+ (block["jobs"] || []).map do |job|
28
+ {
29
+ id: job["jobid"],
30
+ name: job["name"],
31
+ block_name: block_name,
32
+ result: block["result"]
33
+ }
34
+ end
35
+ end
36
+ end
37
+
38
+ def fetch_log(job_id:)
39
+ run_cmd("sem logs #{job_id}")
40
+ end
41
+
42
+ private
43
+
44
+ def run_cmd(cmd)
45
+ output = `#{cmd} 2>/dev/null`
46
+ raise Error, "Command failed (exit #{$?.exitstatus}): #{cmd}" unless $?.success?
47
+ output
48
+ end
49
+
50
+ def parse_workflow_line(line)
51
+ parts = line.strip.split(/\s{2,}/)
52
+ return nil if parts.length < 4
53
+
54
+ {
55
+ id: parts[0],
56
+ pipeline_id: parts[1],
57
+ branch: parts[3],
58
+ created_at: parts[2]
59
+ }
60
+ end
61
+ end
62
+
63
+ Configuration.register_provider(:semaphore, Semaphore)
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("tasks/flaky.rake", __dir__)
7
+ end
8
+
9
+ initializer "flaky.middleware" do |app|
10
+ if ENV["FLAKY_CI_SIMULATE"] && Rails.env.test?
11
+ require_relative "middleware/simulate_ci_latency"
12
+ app.middleware.use Flaky::Middleware::SimulateCiLatency
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :flaky do
4
+ desc "Fetch recent CI results (age: duration, default 24h)"
5
+ task :fetch, [:age] => :environment do |_t, args|
6
+ require "flaky/commands/fetch"
7
+ age = args[:age] || "24h"
8
+ Flaky::Commands::Fetch.new(age: age).execute
9
+ end
10
+
11
+ desc "Rank flaky tests by failure frequency (since: days, default 30)"
12
+ task :rank, [:since] => :environment do |_t, args|
13
+ require "flaky/commands/rank"
14
+ since = (args[:since] || 30).to_i
15
+ Flaky::Commands::Rank.new(since_days: since).execute
16
+ end
17
+
18
+ desc "Show failure history for a spec (spec_location: file:line)"
19
+ task :history, [:spec_location] => :environment do |_t, args|
20
+ require "flaky/commands/history"
21
+ raise "Usage: rake flaky:history[path/to/spec.rb:42]" unless args[:spec_location]
22
+ Flaky::Commands::History.new(spec_location: args[:spec_location]).execute
23
+ end
24
+
25
+ desc "Stress test a spec (spec, iterations, seed, ci)"
26
+ task :stress, [:spec, :iterations, :seed, :ci] => :environment do |_t, args|
27
+ require "flaky/commands/stress"
28
+ raise "Usage: rake flaky:stress[path/to/spec.rb:42,20,12345,true]" unless args[:spec]
29
+ Flaky::Commands::Stress.new(
30
+ spec_location: args[:spec],
31
+ iterations: (args[:iterations] || 20).to_i,
32
+ seed: args[:seed]&.to_i,
33
+ ci_simulate: args[:ci] == "true"
34
+ ).execute
35
+ end
36
+
37
+ desc "Summary dashboard of flaky test status"
38
+ task report: :environment do
39
+ require "flaky/commands/report"
40
+ Flaky::Commands::Report.new.execute
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flaky
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flaky.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flaky/version"
4
+ require_relative "flaky/configuration"
5
+ require_relative "flaky/providers/semaphore"
6
+ require_relative "flaky/providers/github_actions"
7
+ require_relative "flaky/railtie" if defined?(Rails::Railtie)
8
+
9
+ module Flaky
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def provider
22
+ configuration.provider_instance
23
+ end
24
+
25
+ def register_provider(name, klass)
26
+ Configuration.register_provider(name, klass)
27
+ end
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flaky-friend
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Flytedesk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sqlite3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: Fetches CI test results, stores failures in SQLite, ranks by frequency,
69
+ and reproduces under simulated CI conditions.
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - LICENSE
75
+ - README.md
76
+ - lib/flaky.rb
77
+ - lib/flaky/commands/fetch.rb
78
+ - lib/flaky/commands/history.rb
79
+ - lib/flaky/commands/rank.rb
80
+ - lib/flaky/commands/report.rb
81
+ - lib/flaky/commands/stress.rb
82
+ - lib/flaky/configuration.rb
83
+ - lib/flaky/database.rb
84
+ - lib/flaky/log_parser.rb
85
+ - lib/flaky/middleware/simulate_ci_latency.rb
86
+ - lib/flaky/providers/base.rb
87
+ - lib/flaky/providers/github_actions.rb
88
+ - lib/flaky/providers/semaphore.rb
89
+ - lib/flaky/railtie.rb
90
+ - lib/flaky/tasks/flaky.rake
91
+ - lib/flaky/version.rb
92
+ homepage: https://github.com/Flytedesk/flaky
93
+ licenses:
94
+ - MIT
95
+ metadata: {}
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '3.1'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.6.9
111
+ specification_version: 4
112
+ summary: Track, rank, and reproduce flaky CI test failures
113
+ test_files: []