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 +7 -0
- data/LICENSE +21 -0
- data/README.md +215 -0
- data/lib/flaky/commands/fetch.rb +75 -0
- data/lib/flaky/commands/history.rb +78 -0
- data/lib/flaky/commands/rank.rb +66 -0
- data/lib/flaky/commands/report.rb +90 -0
- data/lib/flaky/commands/stress.rb +83 -0
- data/lib/flaky/configuration.rb +33 -0
- data/lib/flaky/database.rb +95 -0
- data/lib/flaky/log_parser.rb +75 -0
- data/lib/flaky/middleware/simulate_ci_latency.rb +23 -0
- data/lib/flaky/providers/base.rb +30 -0
- data/lib/flaky/providers/github_actions.rb +78 -0
- data/lib/flaky/providers/semaphore.rb +65 -0
- data/lib/flaky/railtie.rb +16 -0
- data/lib/flaky/tasks/flaky.rake +42 -0
- data/lib/flaky/version.rb +5 -0
- data/lib/flaky.rb +29 -0
- metadata +113 -0
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
|
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: []
|