devformance 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/README.md +205 -0
- data/app/assets/builds/tailwind.css +2 -0
- data/app/assets/images/icon.png +0 -0
- data/app/assets/images/icon.svg +68 -0
- data/app/assets/stylesheets/devmetrics/dashboard.css +476 -0
- data/app/assets/stylesheets/devmetrics_live/application.css +10 -0
- data/app/assets/tailwind/application.css +1 -0
- data/app/channels/application_cable/channel.rb +4 -0
- data/app/channels/application_cable/connection.rb +4 -0
- data/app/channels/devformance/metrics_channel.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/controllers/devformance/application_controller.rb +19 -0
- data/app/controllers/devformance/icons_controller.rb +21 -0
- data/app/controllers/devformance/metrics_controller.rb +41 -0
- data/app/controllers/devformance/playground_controller.rb +89 -0
- data/app/helpers/application_helper.rb +9 -0
- data/app/helpers/metrics_helper.rb +2 -0
- data/app/helpers/playground_helper.rb +2 -0
- data/app/javascript/devformance/channels/consumer.js +2 -0
- data/app/javascript/devformance/channels/index.js +1 -0
- data/app/javascript/devformance/controllers/application.js +9 -0
- data/app/javascript/devformance/controllers/hello_controller.js +7 -0
- data/app/javascript/devformance/controllers/index.js +14 -0
- data/app/javascript/devformance/controllers/metrics_controller.js +364 -0
- data/app/javascript/devformance/controllers/playground_controller.js +33 -0
- data/app/javascript/devmetrics.js +4 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/devformance/file_runner_job.rb +318 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/devformance/file_result.rb +14 -0
- data/app/models/devformance/run.rb +19 -0
- data/app/models/devformance/slow_query.rb +5 -0
- data/app/views/devformance/metrics/index.html.erb +79 -0
- data/app/views/devformance/playground/run.html.erb +63 -0
- data/app/views/layouts/devformance/application.html.erb +856 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/metrics/index.html.erb +334 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/config/BUSINESS_LOGIC_PLAN.md +1244 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +4 -0
- data/config/cable.yml +17 -0
- data/config/cache.yml +16 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +98 -0
- data/config/deploy.yml +116 -0
- data/config/engine_routes.rb +13 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +84 -0
- data/config/environments/production.rb +90 -0
- data/config/environments/test.rb +59 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/content_security_policy.rb +25 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/locales/en.yml +31 -0
- data/config/master.key +1 -0
- data/config/puma.rb +41 -0
- data/config/queue.yml +22 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +20 -0
- data/config/storage.yml +34 -0
- data/db/migrate/20260317144616_create_slow_queries.rb +13 -0
- data/db/migrate/20260317175630_create_performance_runs.rb +14 -0
- data/db/migrate/20260317195043_add_run_id_to_slow_queries.rb +10 -0
- data/db/migrate/20260319000001_create_devformance_runs.rb +20 -0
- data/db/migrate/20260319000002_create_devformance_file_results.rb +29 -0
- data/db/migrate/20260319000003_add_columns_to_slow_queries.rb +7 -0
- data/lib/devformance/bullet_log_parser.rb +47 -0
- data/lib/devformance/compatibility.rb +12 -0
- data/lib/devformance/coverage_setup.rb +33 -0
- data/lib/devformance/engine.rb +80 -0
- data/lib/devformance/log_writer.rb +29 -0
- data/lib/devformance/run_orchestrator.rb +58 -0
- data/lib/devformance/sql_instrumentor.rb +29 -0
- data/lib/devformance/test_framework/base.rb +43 -0
- data/lib/devformance/test_framework/coverage_helper.rb +76 -0
- data/lib/devformance/test_framework/detector.rb +26 -0
- data/lib/devformance/test_framework/minitest.rb +71 -0
- data/lib/devformance/test_framework/registry.rb +24 -0
- data/lib/devformance/test_framework/rspec.rb +60 -0
- data/lib/devformance/test_helper.rb +42 -0
- data/lib/devformance/version.rb +3 -0
- data/lib/devformance.rb +196 -0
- data/lib/generators/devformance/install/install_generator.rb +73 -0
- data/lib/generators/devformance/install/templates/add_columns_to_slow_queries.rb.erb +7 -0
- data/lib/generators/devformance/install/templates/add_run_id_to_slow_queries.rb.erb +10 -0
- data/lib/generators/devformance/install/templates/create_devformance_file_results.rb.erb +29 -0
- data/lib/generators/devformance/install/templates/create_devformance_runs.rb.erb +20 -0
- data/lib/generators/devformance/install/templates/create_performance_runs.rb.erb +14 -0
- data/lib/generators/devformance/install/templates/create_slow_queries.rb.erb +13 -0
- data/lib/generators/devformance/install/templates/initializer.rb +23 -0
- data/lib/tasks/devformance.rake +45 -0
- data/spec/fixtures/devformance/devformance_run.rb +27 -0
- data/spec/fixtures/devformance/file_result.rb +34 -0
- data/spec/fixtures/devformance/slow_query.rb +11 -0
- data/spec/lib/devmetrics/log_writer_spec.rb +81 -0
- data/spec/lib/devmetrics/run_orchestrator_spec.rb +102 -0
- data/spec/lib/devmetrics/sql_instrumentor_spec.rb +115 -0
- data/spec/models/devmetrics/file_result_spec.rb +87 -0
- data/spec/models/devmetrics/run_spec.rb +66 -0
- data/spec/models/query_log_spec.rb +21 -0
- data/spec/rails_helper.rb +20 -0
- data/spec/requests/devmetrics/metrics_controller_spec.rb +149 -0
- data/spec/requests/devmetrics_pages_spec.rb +12 -0
- data/spec/requests/performance_spec.rb +17 -0
- data/spec/requests/slow_perf_spec.rb +9 -0
- data/spec/spec_helper.rb +114 -0
- data/spec/support/devmetrics_formatter.rb +106 -0
- data/spec/support/devmetrics_metrics.rb +37 -0
- data/spec/support/factory_bot.rb +3 -0
- metadata +200 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
# DevMetrics — Parallel Real-Time Test Runner
|
|
2
|
+
## Full Implementation Plan for Claude Code
|
|
3
|
+
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Replace the single sequential `PerformanceTestRunnerJob` with a **fan-out architecture**: one
|
|
9
|
+
background job per spec file, all running in parallel, each streaming live output to its own
|
|
10
|
+
panel in the browser via ActionCable. Every panel shows a live terminal, slow queries, N+1
|
|
11
|
+
detections, and per-file coverage as they happen.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Architecture Summary
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
POST /devmetrics/run_tests
|
|
19
|
+
→ RunOrchestrator.call
|
|
20
|
+
→ Dir.glob spec/requests/**/*_spec.rb
|
|
21
|
+
→ creates Run record (run_id, status: :running)
|
|
22
|
+
→ enqueues FileRunnerJob per file (all at once)
|
|
23
|
+
→ broadcasts { type: "run_started", files: [...file_keys] }
|
|
24
|
+
|
|
25
|
+
FileRunnerJob (N jobs in parallel, one per file)
|
|
26
|
+
→ Open3.popen2e("bundle exec rspec <file> ...")
|
|
27
|
+
→ ActiveSupport::Notifications subscriber (scoped to job thread)
|
|
28
|
+
→ LogWriter appends to log/devmetrics/runs/<run_id>/<file_key>.log
|
|
29
|
+
→ broadcasts per-file events to "devmetrics:file:<file_key>"
|
|
30
|
+
|
|
31
|
+
Browser (Stimulus metrics_controller.js)
|
|
32
|
+
→ subscribes to "devmetrics:run" for run_started
|
|
33
|
+
→ on run_started: creates panel + subscription per file_key
|
|
34
|
+
→ each subscription handles: test_output, slow_query, n1_detected,
|
|
35
|
+
coverage_update, file_complete
|
|
36
|
+
→ panels collapse/expand, progress bar, live terminal, sidebar stats
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Phase 1 — Database & Models
|
|
42
|
+
|
|
43
|
+
### Step 1.1 — Generate migrations
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
rails g devmetrics:install # already exists — skip if done
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Generate new migration inside the gem:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# From the gem root
|
|
53
|
+
rails g migration CreateDevmetricsRuns \
|
|
54
|
+
run_id:string status:integer started_at:datetime finished_at:datetime \
|
|
55
|
+
total_files:integer completed_files:integer --no-test-framework
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
rails g migration CreateDevmetricsFileResults \
|
|
60
|
+
run_id:string file_key:string file_path:string status:integer \
|
|
61
|
+
total_tests:integer passed_tests:integer failed_tests:integer \
|
|
62
|
+
slow_query_count:integer n1_count:integer coverage:float \
|
|
63
|
+
duration_ms:integer log_path:string --no-test-framework
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Step 1.2 — Models
|
|
67
|
+
|
|
68
|
+
**`lib/devmetrics/models/run.rb`**
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
module Devmetrics
|
|
72
|
+
class Run < ActiveRecord::Base
|
|
73
|
+
self.table_name = "devmetrics_runs"
|
|
74
|
+
|
|
75
|
+
enum :status, { pending: 0, running: 1, completed: 2, failed: 3 }
|
|
76
|
+
|
|
77
|
+
has_many :file_results, foreign_key: :run_id, primary_key: :run_id,
|
|
78
|
+
class_name: "Devmetrics::FileResult"
|
|
79
|
+
|
|
80
|
+
def self.create_for_files(file_paths)
|
|
81
|
+
create!(
|
|
82
|
+
run_id: SecureRandom.hex(8),
|
|
83
|
+
status: :running,
|
|
84
|
+
started_at: Time.current,
|
|
85
|
+
total_files: file_paths.size
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**`lib/devmetrics/models/file_result.rb`**
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
module Devmetrics
|
|
96
|
+
class FileResult < ActiveRecord::Base
|
|
97
|
+
self.table_name = "devmetrics_file_results"
|
|
98
|
+
|
|
99
|
+
enum :status, { pending: 0, running: 1, passed: 2, failed: 3 }
|
|
100
|
+
|
|
101
|
+
belongs_to :run, foreign_key: :run_id, primary_key: :run_id,
|
|
102
|
+
class_name: "Devmetrics::Run"
|
|
103
|
+
|
|
104
|
+
def self.file_key_for(file_path)
|
|
105
|
+
File.basename(file_path, ".rb").gsub(/[^a-z0-9_]/, "_")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Phase 2 — Core Backend Services
|
|
114
|
+
|
|
115
|
+
### Step 2.1 — LogWriter
|
|
116
|
+
|
|
117
|
+
**`lib/devmetrics/log_writer.rb`**
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
module Devmetrics
|
|
121
|
+
class LogWriter
|
|
122
|
+
LOG_BASE = Rails.root.join("log", "devmetrics", "runs")
|
|
123
|
+
|
|
124
|
+
def self.open(run_id, file_key)
|
|
125
|
+
dir = LOG_BASE.join(run_id.to_s)
|
|
126
|
+
FileUtils.mkdir_p(dir)
|
|
127
|
+
new(dir.join("#{file_key}.log"))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def initialize(path)
|
|
131
|
+
@path = path
|
|
132
|
+
@file = File.open(path, "w")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def write(line)
|
|
136
|
+
@file.puts(line)
|
|
137
|
+
@file.flush
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def close
|
|
141
|
+
@file.close
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def path
|
|
145
|
+
@path.to_s
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Step 2.2 — SqlInstrumentor
|
|
152
|
+
|
|
153
|
+
Scoped per-job SQL tracking using thread-local state so parallel jobs don't bleed into each other.
|
|
154
|
+
|
|
155
|
+
**`lib/devmetrics/sql_instrumentor.rb`**
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
module Devmetrics
|
|
159
|
+
class SqlInstrumentor
|
|
160
|
+
THREAD_KEY = :devmetrics_sql_collector
|
|
161
|
+
|
|
162
|
+
def self.around_run
|
|
163
|
+
Thread.current[THREAD_KEY] = { queries: [], start: Time.current }
|
|
164
|
+
yield
|
|
165
|
+
ensure
|
|
166
|
+
Thread.current[THREAD_KEY] = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.record(event)
|
|
170
|
+
collector = Thread.current[THREAD_KEY]
|
|
171
|
+
return unless collector
|
|
172
|
+
|
|
173
|
+
ms = event.duration.round(2)
|
|
174
|
+
sql = event.payload[:sql].to_s.strip
|
|
175
|
+
|
|
176
|
+
return if sql.match?(/\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i)
|
|
177
|
+
|
|
178
|
+
collector[:queries] << { sql: sql, ms: ms, at: Time.current.iso8601 }
|
|
179
|
+
collector[:queries].last
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.queries
|
|
183
|
+
Thread.current[THREAD_KEY]&.dig(:queries) || []
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Register the subscriber once in the engine initializer (not per-job):
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# lib/devmetrics/engine.rb (inside the existing Engine class)
|
|
193
|
+
initializer "devmetrics.sql_notifications" do
|
|
194
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
195
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
196
|
+
::Devmetrics::SqlInstrumentor.record(event)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Step 2.3 — FileRunnerJob
|
|
202
|
+
|
|
203
|
+
**`lib/devmetrics/jobs/file_runner_job.rb`**
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
module Devmetrics
|
|
207
|
+
class FileRunnerJob < ActiveJob::Base
|
|
208
|
+
queue_as :devmetrics
|
|
209
|
+
|
|
210
|
+
SLOW_THRESHOLD_MS = -> { Devmetrics.configuration.slow_query_threshold_ms }
|
|
211
|
+
|
|
212
|
+
def perform(run_id:, file_path:, file_key:)
|
|
213
|
+
result = ::Devmetrics::FileResult.find_by!(run_id: run_id, file_key: file_key)
|
|
214
|
+
result.update!(status: :running)
|
|
215
|
+
|
|
216
|
+
log = ::Devmetrics::LogWriter.open(run_id, file_key)
|
|
217
|
+
stream = "devmetrics:file:#{file_key}:#{run_id}"
|
|
218
|
+
|
|
219
|
+
broadcast(stream, type: "file_started", file_key: file_key)
|
|
220
|
+
|
|
221
|
+
::Devmetrics::SqlInstrumentor.around_run do
|
|
222
|
+
run_rspec(file_path, stream, log, result)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
flush_sql_results(stream, log, result)
|
|
226
|
+
write_coverage(stream, log, result, file_path)
|
|
227
|
+
finalize(stream, log, result, run_id)
|
|
228
|
+
rescue => e
|
|
229
|
+
broadcast(stream, type: "file_error", message: e.message)
|
|
230
|
+
result&.update!(status: :failed)
|
|
231
|
+
ensure
|
|
232
|
+
log&.close
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def run_rspec(file_path, stream, log, result)
|
|
238
|
+
cmd = [
|
|
239
|
+
"bundle", "exec", "rspec", file_path,
|
|
240
|
+
"--format", "documentation",
|
|
241
|
+
"--format", "json",
|
|
242
|
+
"--out", json_output_path(result.run_id, result.file_key)
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
passed = 0
|
|
246
|
+
failed = 0
|
|
247
|
+
|
|
248
|
+
Open3.popen2e(*cmd, chdir: Rails.root.to_s) do |_stdin, stdout_err, wait_thr|
|
|
249
|
+
stdout_err.each_line do |raw_line|
|
|
250
|
+
line = raw_line.chomp
|
|
251
|
+
log.write(line)
|
|
252
|
+
|
|
253
|
+
event_type = classify_line(line)
|
|
254
|
+
broadcast(stream, type: "test_output", line: line, event_type: event_type)
|
|
255
|
+
|
|
256
|
+
passed += 1 if event_type == "pass"
|
|
257
|
+
failed += 1 if event_type == "fail"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
exit_status = wait_thr.value
|
|
261
|
+
status = exit_status.success? && failed == 0 ? :passed : :failed
|
|
262
|
+
result.update!(
|
|
263
|
+
status: status,
|
|
264
|
+
passed_tests: passed,
|
|
265
|
+
failed_tests: failed,
|
|
266
|
+
total_tests: passed + failed
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def flush_sql_results(stream, log, result)
|
|
272
|
+
queries = ::Devmetrics::SqlInstrumentor.queries
|
|
273
|
+
slow = queries.select { |q| q[:ms] >= SLOW_THRESHOLD_MS.call }
|
|
274
|
+
n1_groups = detect_n1_patterns(queries)
|
|
275
|
+
|
|
276
|
+
slow.each do |q|
|
|
277
|
+
entry = { sql: q[:sql].truncate(200), ms: q[:ms] }
|
|
278
|
+
log.write(" [SLOW #{q[:ms]}ms] #{q[:sql].truncate(120)}")
|
|
279
|
+
broadcast(stream, type: "slow_query", query: entry)
|
|
280
|
+
|
|
281
|
+
::Devmetrics::SlowQuery.create!(
|
|
282
|
+
run_id: result.run_id,
|
|
283
|
+
file_key: result.file_key,
|
|
284
|
+
sql: q[:sql],
|
|
285
|
+
duration_ms: q[:ms]
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
n1_groups.each do |pattern, count|
|
|
290
|
+
msg = "N+1 detected: #{pattern} (#{count}x) — add includes(:#{n1_association(pattern)})"
|
|
291
|
+
log.write(" [N+1] #{msg}")
|
|
292
|
+
broadcast(stream, type: "n1_detected", message: msg, pattern: pattern, count: count)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
result.update!(
|
|
296
|
+
slow_query_count: slow.size,
|
|
297
|
+
n1_count: n1_groups.size
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def write_coverage(stream, log, result, file_path)
|
|
302
|
+
json_path = json_output_path(result.run_id, result.file_key)
|
|
303
|
+
return unless File.exist?(json_path)
|
|
304
|
+
|
|
305
|
+
data = JSON.parse(File.read(json_path)) rescue {}
|
|
306
|
+
summary = data.dig("summary", "example_count")
|
|
307
|
+
return unless summary
|
|
308
|
+
|
|
309
|
+
# SimpleCov resultset if present
|
|
310
|
+
resultset_path = Rails.root.join("coverage", ".resultset.json")
|
|
311
|
+
if File.exist?(resultset_path)
|
|
312
|
+
rs = JSON.parse(File.read(resultset_path)) rescue {}
|
|
313
|
+
pct = extract_coverage_pct(rs, file_path)
|
|
314
|
+
if pct
|
|
315
|
+
log.write(" [COVERAGE] #{pct.round(1)}%")
|
|
316
|
+
broadcast(stream, type: "coverage_update", pct: pct.round(1))
|
|
317
|
+
result.update!(coverage: pct.round(1))
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def finalize(stream, log, result, run_id)
|
|
323
|
+
log.write("")
|
|
324
|
+
log.write("=" * 60)
|
|
325
|
+
log.write("Result: #{result.status.upcase} | " \
|
|
326
|
+
"#{result.total_tests} tests, #{result.failed_tests} failures | " \
|
|
327
|
+
"#{result.slow_query_count} slow queries | #{result.n1_count} N+1 issues")
|
|
328
|
+
|
|
329
|
+
broadcast(stream, type: "file_complete",
|
|
330
|
+
status: result.status,
|
|
331
|
+
passed: result.passed_tests,
|
|
332
|
+
failed: result.failed_tests,
|
|
333
|
+
slow_count: result.slow_query_count,
|
|
334
|
+
n1_count: result.n1_count,
|
|
335
|
+
coverage: result.coverage,
|
|
336
|
+
duration_ms: result.duration_ms,
|
|
337
|
+
log_path: result.log_path
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Increment run-level counter and check if all files done
|
|
341
|
+
run = ::Devmetrics::Run.find_by(run_id: run_id)
|
|
342
|
+
if run
|
|
343
|
+
run.with_lock do
|
|
344
|
+
run.increment!(:completed_files)
|
|
345
|
+
if run.completed_files >= run.total_files
|
|
346
|
+
run.update!(status: :completed, finished_at: Time.current)
|
|
347
|
+
ActionCable.server.broadcast(
|
|
348
|
+
"devmetrics:run:#{run_id}",
|
|
349
|
+
type: "run_complete",
|
|
350
|
+
run_id: run_id,
|
|
351
|
+
total_files: run.total_files
|
|
352
|
+
)
|
|
353
|
+
write_run_summary(run)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def broadcast(stream, payload)
|
|
360
|
+
ActionCable.server.broadcast(stream, payload)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def classify_line(line)
|
|
364
|
+
if line.match?(/^\s+\d+ examples?,/)
|
|
365
|
+
"summary"
|
|
366
|
+
elsif line.match?(/^\s*[·.]\s/) || line.strip.start_with?(".")
|
|
367
|
+
"pass"
|
|
368
|
+
elsif line.match?(/^\s*[F!]\s/) || line.strip.start_with?("F")
|
|
369
|
+
"fail"
|
|
370
|
+
elsif line.match?(/^\s*\*\s/) || line.strip.start_with?("*")
|
|
371
|
+
"pending"
|
|
372
|
+
elsif line.include?("ERROR") || line.include?("Error:")
|
|
373
|
+
"error"
|
|
374
|
+
else
|
|
375
|
+
"info"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def detect_n1_patterns(queries)
|
|
380
|
+
pattern_counts = Hash.new(0)
|
|
381
|
+
queries.each do |q|
|
|
382
|
+
normalized = q[:sql].gsub(/\d+/, "?").gsub(/'[^']*'/, "?").strip
|
|
383
|
+
pattern_counts[normalized] += 1
|
|
384
|
+
end
|
|
385
|
+
pattern_counts.select { |_, count| count >= 3 }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def n1_association(pattern)
|
|
389
|
+
pattern.match(/FROM "?(\w+)"?/i)&.captures&.first&.singularize || "association"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def extract_coverage_pct(resultset, file_path)
|
|
393
|
+
rel = file_path.sub(Rails.root.to_s + "/", "")
|
|
394
|
+
all_lines = resultset.values.flat_map { |r| r.dig("coverage", rel)&.compact }.compact
|
|
395
|
+
return nil if all_lines.empty?
|
|
396
|
+
covered = all_lines.count { |v| v.to_i > 0 }
|
|
397
|
+
(covered.to_f / all_lines.size * 100).round(1)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def json_output_path(run_id, file_key)
|
|
401
|
+
dir = Rails.root.join("log", "devmetrics", "runs", run_id.to_s)
|
|
402
|
+
FileUtils.mkdir_p(dir)
|
|
403
|
+
dir.join("#{file_key}.json").to_s
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def write_run_summary(run)
|
|
407
|
+
results = run.file_results
|
|
408
|
+
dir = Rails.root.join("log", "devmetrics", "runs", run.run_id.to_s)
|
|
409
|
+
summary = {
|
|
410
|
+
run_id: run.run_id,
|
|
411
|
+
started_at: run.started_at.iso8601,
|
|
412
|
+
finished_at: run.finished_at.iso8601,
|
|
413
|
+
total_files: run.total_files,
|
|
414
|
+
total_tests: results.sum(:total_tests),
|
|
415
|
+
total_passed: results.sum(:passed_tests),
|
|
416
|
+
total_failed: results.sum(:failed_tests),
|
|
417
|
+
total_slow: results.sum(:slow_query_count),
|
|
418
|
+
total_n1: results.sum(:n1_count),
|
|
419
|
+
avg_coverage: (results.average(:coverage)&.round(1) || 0),
|
|
420
|
+
files: results.map { |r|
|
|
421
|
+
{ key: r.file_key, status: r.status, coverage: r.coverage,
|
|
422
|
+
slow: r.slow_query_count, n1: r.n1_count }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
File.write(dir.join("_run_summary.json"), JSON.pretty_generate(summary))
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Step 2.4 — RunOrchestrator
|
|
432
|
+
|
|
433
|
+
**`lib/devmetrics/run_orchestrator.rb`**
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
module Devmetrics
|
|
437
|
+
class RunOrchestrator
|
|
438
|
+
SPEC_GLOB = "spec/requests/**/*_spec.rb"
|
|
439
|
+
|
|
440
|
+
def self.call
|
|
441
|
+
new.call
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def call
|
|
445
|
+
file_paths = discover_files
|
|
446
|
+
return { error: "No request specs found in #{SPEC_GLOB}" } if file_paths.empty?
|
|
447
|
+
|
|
448
|
+
run = ::Devmetrics::Run.create_for_files(file_paths)
|
|
449
|
+
|
|
450
|
+
file_metas = file_paths.map do |path|
|
|
451
|
+
file_key = ::Devmetrics::FileResult.file_key_for(path)
|
|
452
|
+
::Devmetrics::FileResult.create!(
|
|
453
|
+
run_id: run.run_id,
|
|
454
|
+
file_key: file_key,
|
|
455
|
+
file_path: path,
|
|
456
|
+
status: :pending
|
|
457
|
+
)
|
|
458
|
+
{ file_key: file_key, file_path: path, display_name: path.sub(Rails.root.to_s + "/", "") }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
ActionCable.server.broadcast(
|
|
462
|
+
"devmetrics:run:#{run.run_id}",
|
|
463
|
+
type: "run_started",
|
|
464
|
+
run_id: run.run_id,
|
|
465
|
+
files: file_metas
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
file_metas.each do |meta|
|
|
469
|
+
::Devmetrics::FileRunnerJob.perform_later(
|
|
470
|
+
run_id: run.run_id,
|
|
471
|
+
file_path: meta[:file_path],
|
|
472
|
+
file_key: meta[:file_key]
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
{ run_id: run.run_id, file_count: file_metas.size, files: file_metas }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
private
|
|
480
|
+
|
|
481
|
+
def discover_files
|
|
482
|
+
all = Dir.glob(Rails.root.join(SPEC_GLOB)).sort
|
|
483
|
+
|
|
484
|
+
# Prefer files tagged with devmetrics (require 'devmetrics' or devmetrics: true)
|
|
485
|
+
tagged = all.select { |f| File.read(f).match?(/devmetrics/i) }
|
|
486
|
+
tagged.any? ? tagged : all
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Phase 3 — ActionCable Channel
|
|
495
|
+
|
|
496
|
+
### Step 3.1 — Update MetricsChannel
|
|
497
|
+
|
|
498
|
+
**`app/channels/devmetrics/metrics_channel.rb`**
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
module Devmetrics
|
|
502
|
+
class MetricsChannel < ActionCable::Channel::Base
|
|
503
|
+
def subscribed
|
|
504
|
+
case params[:stream_type]
|
|
505
|
+
when "run"
|
|
506
|
+
run_id = params[:run_id]
|
|
507
|
+
stream_from "devmetrics:run:#{run_id}" if run_id.present?
|
|
508
|
+
when "file"
|
|
509
|
+
file_key = params[:file_key]
|
|
510
|
+
run_id = params[:run_id]
|
|
511
|
+
if file_key.present? && run_id.present?
|
|
512
|
+
stream_from "devmetrics:file:#{file_key}:#{run_id}"
|
|
513
|
+
end
|
|
514
|
+
else
|
|
515
|
+
# Legacy support — global stream
|
|
516
|
+
stream_from "devmetrics:metrics"
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def unsubscribed
|
|
521
|
+
stop_all_streams
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Phase 4 — Controller
|
|
530
|
+
|
|
531
|
+
### Step 4.1 — MetricsController#run_tests
|
|
532
|
+
|
|
533
|
+
Replace the existing `run_tests` action:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
# app/controllers/devmetrics/metrics_controller.rb
|
|
537
|
+
|
|
538
|
+
def run_tests
|
|
539
|
+
result = ::Devmetrics::RunOrchestrator.call
|
|
540
|
+
if result[:error]
|
|
541
|
+
render json: { error: result[:error] }, status: :unprocessable_entity
|
|
542
|
+
else
|
|
543
|
+
render json: result, status: :accepted
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def run_status
|
|
548
|
+
run = ::Devmetrics::Run.find_by(run_id: params[:run_id])
|
|
549
|
+
return render json: { error: "Not found" }, status: :not_found unless run
|
|
550
|
+
|
|
551
|
+
render json: {
|
|
552
|
+
run_id: run.run_id,
|
|
553
|
+
status: run.status,
|
|
554
|
+
files: run.file_results.map { |r|
|
|
555
|
+
{ file_key: r.file_key, file_path: r.file_path, status: r.status,
|
|
556
|
+
coverage: r.coverage, slow_query_count: r.slow_query_count, n1_count: r.n1_count }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def download_log
|
|
562
|
+
result = ::Devmetrics::FileResult.find_by(
|
|
563
|
+
run_id: params[:run_id], file_key: params[:file_key]
|
|
564
|
+
)
|
|
565
|
+
return render plain: "Not found", status: :not_found unless result&.log_path
|
|
566
|
+
return render plain: "Log not ready", status: :not_found unless File.exist?(result.log_path)
|
|
567
|
+
|
|
568
|
+
send_file result.log_path, type: "text/plain", disposition: "attachment"
|
|
569
|
+
end
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### Step 4.2 — Routes
|
|
573
|
+
|
|
574
|
+
```ruby
|
|
575
|
+
# In the engine's routes.rb
|
|
576
|
+
Devmetrics::Engine.routes.draw do
|
|
577
|
+
root to: "metrics#index"
|
|
578
|
+
|
|
579
|
+
get "playground", to: "metrics#playground"
|
|
580
|
+
post "run_tests", to: "metrics#run_tests"
|
|
581
|
+
post "playground", to: "metrics#playground_execute"
|
|
582
|
+
get "runs/:run_id/status", to: "metrics#run_status"
|
|
583
|
+
get "runs/:run_id/logs/:file_key/download", to: "metrics#download_log"
|
|
584
|
+
|
|
585
|
+
mount ActionCable.server => "/cable"
|
|
586
|
+
end
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Phase 5 — Frontend (Stimulus + ActionCable)
|
|
592
|
+
|
|
593
|
+
### Step 5.1 — HTML template
|
|
594
|
+
|
|
595
|
+
**`app/views/devmetrics/metrics/index.html.erb`**
|
|
596
|
+
|
|
597
|
+
Key structure only — fill in your existing layout wrapper:
|
|
598
|
+
|
|
599
|
+
```erb
|
|
600
|
+
<div data-controller="metrics"
|
|
601
|
+
data-metrics-cable-url-value="<%= devmetrics_mount_path %>/cable">
|
|
602
|
+
|
|
603
|
+
<%# Header + controls %>
|
|
604
|
+
<div class="dm-header">
|
|
605
|
+
<h1>DevMetrics</h1>
|
|
606
|
+
<button data-action="click->metrics#runTests" data-metrics-target="runBtn">
|
|
607
|
+
Run performance tests
|
|
608
|
+
</button>
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
<%# Summary stat cards — updated live %>
|
|
612
|
+
<div class="dm-summary" data-metrics-target="summary">
|
|
613
|
+
<div class="dm-stat" data-stat="totalTests">
|
|
614
|
+
<div class="dm-stat-label">Tests run</div>
|
|
615
|
+
<div class="dm-stat-value" data-metrics-target="statTotalTests">—</div>
|
|
616
|
+
</div>
|
|
617
|
+
<div class="dm-stat" data-stat="slowQueries">
|
|
618
|
+
<div class="dm-stat-label">Slow queries</div>
|
|
619
|
+
<div class="dm-stat-value" data-metrics-target="statSlowQueries">—</div>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="dm-stat" data-stat="n1Issues">
|
|
622
|
+
<div class="dm-stat-label">N+1 issues</div>
|
|
623
|
+
<div class="dm-stat-value" data-metrics-target="statN1Issues">—</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="dm-stat" data-stat="coverage">
|
|
626
|
+
<div class="dm-stat-label">Avg coverage</div>
|
|
627
|
+
<div class="dm-stat-value" data-metrics-target="statCoverage">—</div>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
<%# File panels — injected dynamically by JS %>
|
|
632
|
+
<div class="dm-file-list" data-metrics-target="fileList"></div>
|
|
633
|
+
|
|
634
|
+
</div>
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Step 5.2 — Stimulus controller
|
|
638
|
+
|
|
639
|
+
**`app/javascript/devmetrics/controllers/metrics_controller.js`**
|
|
640
|
+
|
|
641
|
+
```javascript
|
|
642
|
+
import { Controller } from "@hotwired/stimulus"
|
|
643
|
+
import consumer from "../channels/consumer"
|
|
644
|
+
|
|
645
|
+
export default class extends Controller {
|
|
646
|
+
static targets = [
|
|
647
|
+
"runBtn", "fileList", "summary",
|
|
648
|
+
"statTotalTests", "statSlowQueries", "statN1Issues", "statCoverage"
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
static values = { cableUrl: String }
|
|
652
|
+
|
|
653
|
+
connect() {
|
|
654
|
+
this.subscriptions = {} // file_key -> ActionCable subscription
|
|
655
|
+
this.runSubscription = null
|
|
656
|
+
this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
|
|
657
|
+
this.currentRunId = null
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
disconnect() {
|
|
661
|
+
this.teardownSubscriptions()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Run trigger ──────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
async runTests() {
|
|
667
|
+
this.runBtnTarget.disabled = true
|
|
668
|
+
this.runBtnTarget.textContent = "Starting…"
|
|
669
|
+
this.resetState()
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const resp = await fetch(this.runTestsUrl, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: { "X-CSRF-Token": this.csrfToken, "Content-Type": "application/json" }
|
|
675
|
+
})
|
|
676
|
+
const data = await resp.json()
|
|
677
|
+
if (!resp.ok) throw new Error(data.error || "Failed to start run")
|
|
678
|
+
|
|
679
|
+
this.currentRunId = data.run_id
|
|
680
|
+
this.subscribeToRun(data.run_id)
|
|
681
|
+
|
|
682
|
+
} catch (err) {
|
|
683
|
+
this.runBtnTarget.disabled = false
|
|
684
|
+
this.runBtnTarget.textContent = "Run performance tests"
|
|
685
|
+
console.error("DevMetrics run error:", err)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── Run-level subscription ────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
subscribeToRun(runId) {
|
|
692
|
+
this.runSubscription = consumer.subscriptions.create(
|
|
693
|
+
{ channel: "Devmetrics::MetricsChannel", stream_type: "run", run_id: runId },
|
|
694
|
+
{
|
|
695
|
+
received: (data) => this.handleRunEvent(data)
|
|
696
|
+
}
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
handleRunEvent(data) {
|
|
701
|
+
switch (data.type) {
|
|
702
|
+
case "run_started":
|
|
703
|
+
this.runBtnTarget.textContent = `Running (${data.files.length} files)…`
|
|
704
|
+
data.files.forEach(f => {
|
|
705
|
+
this.createFilePanel(f.file_key, f.display_name, data.run_id)
|
|
706
|
+
this.subscribeToFile(f.file_key, data.run_id)
|
|
707
|
+
})
|
|
708
|
+
break
|
|
709
|
+
|
|
710
|
+
case "run_complete":
|
|
711
|
+
this.runBtnTarget.disabled = false
|
|
712
|
+
this.runBtnTarget.textContent = "Run performance tests"
|
|
713
|
+
this.runSubscription?.unsubscribe()
|
|
714
|
+
break
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Per-file subscription ─────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
subscribeToFile(fileKey, runId) {
|
|
721
|
+
const sub = consumer.subscriptions.create(
|
|
722
|
+
{
|
|
723
|
+
channel: "Devmetrics::MetricsChannel",
|
|
724
|
+
stream_type: "file",
|
|
725
|
+
file_key: fileKey,
|
|
726
|
+
run_id: runId
|
|
727
|
+
},
|
|
728
|
+
{ received: (data) => this.handleFileEvent(fileKey, data) }
|
|
729
|
+
)
|
|
730
|
+
this.subscriptions[fileKey] = sub
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
handleFileEvent(fileKey, data) {
|
|
734
|
+
switch (data.type) {
|
|
735
|
+
case "file_started":
|
|
736
|
+
this.setFileDot(fileKey, "running")
|
|
737
|
+
break
|
|
738
|
+
|
|
739
|
+
case "test_output":
|
|
740
|
+
this.appendTerminalLine(fileKey, data.line, data.event_type)
|
|
741
|
+
if (data.event_type === "pass" || data.event_type === "fail") {
|
|
742
|
+
this.advanceProgress(fileKey)
|
|
743
|
+
}
|
|
744
|
+
break
|
|
745
|
+
|
|
746
|
+
case "slow_query":
|
|
747
|
+
this.appendSidebarItem(fileKey, "slow", `${data.query.ms}ms — ${data.query.sql}`)
|
|
748
|
+
this.stats.slow++
|
|
749
|
+
this.updateSummaryStats()
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
case "n1_detected":
|
|
753
|
+
this.appendSidebarItem(fileKey, "n1", data.message)
|
|
754
|
+
this.stats.n1++
|
|
755
|
+
this.updateSummaryStats()
|
|
756
|
+
break
|
|
757
|
+
|
|
758
|
+
case "coverage_update":
|
|
759
|
+
this.setCoverageLabel(fileKey, data.pct)
|
|
760
|
+
this.stats.coverageSum += data.pct
|
|
761
|
+
this.stats.coverageCount++
|
|
762
|
+
this.updateSummaryStats()
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
case "file_complete":
|
|
766
|
+
this.finalizePanel(fileKey, data)
|
|
767
|
+
this.subscriptions[fileKey]?.unsubscribe()
|
|
768
|
+
delete this.subscriptions[fileKey]
|
|
769
|
+
break
|
|
770
|
+
|
|
771
|
+
case "file_error":
|
|
772
|
+
this.setFileDot(fileKey, "error")
|
|
773
|
+
this.appendTerminalLine(fileKey, `ERROR: ${data.message}`, "error")
|
|
774
|
+
break
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── Panel construction ────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
createFilePanel(fileKey, displayName, runId) {
|
|
781
|
+
const panel = document.createElement("div")
|
|
782
|
+
panel.id = `dm-file-${fileKey}`
|
|
783
|
+
panel.className = "dm-file-row"
|
|
784
|
+
panel.innerHTML = this.panelTemplate(fileKey, displayName, runId)
|
|
785
|
+
this.fileListTarget.appendChild(panel)
|
|
786
|
+
// Auto-open the first panel
|
|
787
|
+
if (this.fileListTarget.children.length === 1) {
|
|
788
|
+
this.togglePanel(fileKey)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
panelTemplate(fileKey, displayName, runId) {
|
|
793
|
+
return `
|
|
794
|
+
<div class="dm-file-header" data-action="click->metrics#togglePanel"
|
|
795
|
+
data-file-key="${fileKey}">
|
|
796
|
+
<span class="dm-chevron" id="dm-chev-${fileKey}">▶</span>
|
|
797
|
+
<span class="dm-dot dm-dot--pending" id="dm-dot-${fileKey}"></span>
|
|
798
|
+
<span class="dm-file-name">${displayName}</span>
|
|
799
|
+
<span class="dm-file-meta" id="dm-meta-${fileKey}"></span>
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
<div class="dm-progress-bar">
|
|
803
|
+
<div class="dm-progress-fill" id="dm-prog-${fileKey}" style="width: 0%"></div>
|
|
804
|
+
</div>
|
|
805
|
+
|
|
806
|
+
<div class="dm-panel" id="dm-panel-${fileKey}">
|
|
807
|
+
<div class="dm-terminal" id="dm-term-${fileKey}"></div>
|
|
808
|
+
<div class="dm-sidebar">
|
|
809
|
+
<div class="dm-sidebar-section">
|
|
810
|
+
<div class="dm-sidebar-label">Slow queries</div>
|
|
811
|
+
<div id="dm-slow-${fileKey}"></div>
|
|
812
|
+
</div>
|
|
813
|
+
<div class="dm-sidebar-section">
|
|
814
|
+
<div class="dm-sidebar-label">N+1 issues</div>
|
|
815
|
+
<div id="dm-n1-${fileKey}"></div>
|
|
816
|
+
</div>
|
|
817
|
+
<div class="dm-sidebar-section">
|
|
818
|
+
<div class="dm-sidebar-label">Coverage</div>
|
|
819
|
+
<div id="dm-cov-${fileKey}" class="dm-sidebar-cov">—</div>
|
|
820
|
+
</div>
|
|
821
|
+
<div class="dm-sidebar-section">
|
|
822
|
+
<a href="${this.logDownloadUrl(runId, fileKey)}"
|
|
823
|
+
class="dm-log-link" id="dm-log-${fileKey}" style="display:none">
|
|
824
|
+
Download log
|
|
825
|
+
</a>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
`
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
togglePanel(fileKey) {
|
|
833
|
+
// Called by data-action click or programmatically
|
|
834
|
+
if (typeof fileKey !== "string") {
|
|
835
|
+
// Called from data-action — extract from dataset
|
|
836
|
+
fileKey = fileKey.currentTarget.dataset.fileKey
|
|
837
|
+
}
|
|
838
|
+
const panel = document.getElementById(`dm-panel-${fileKey}`)
|
|
839
|
+
const chev = document.getElementById(`dm-chev-${fileKey}`)
|
|
840
|
+
const open = panel.classList.toggle("dm-panel--open")
|
|
841
|
+
chev.classList.toggle("dm-chevron--open", open)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── Panel update helpers ──────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
appendTerminalLine(fileKey, text, eventType = "info") {
|
|
847
|
+
const term = document.getElementById(`dm-term-${fileKey}`)
|
|
848
|
+
if (!term) return
|
|
849
|
+
const line = document.createElement("div")
|
|
850
|
+
line.className = `dm-term-line dm-term-line--${eventType}`
|
|
851
|
+
line.textContent = text
|
|
852
|
+
term.appendChild(line)
|
|
853
|
+
term.scrollTop = term.scrollHeight
|
|
854
|
+
|
|
855
|
+
this.stats.tests += (eventType === "pass" || eventType === "fail") ? 1 : 0
|
|
856
|
+
this.updateSummaryStats()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
appendSidebarItem(fileKey, type, text) {
|
|
860
|
+
const container = document.getElementById(`dm-${type}-${fileKey}`)
|
|
861
|
+
if (!container) return
|
|
862
|
+
const item = document.createElement("div")
|
|
863
|
+
item.className = `dm-sidebar-item dm-sidebar-item--${type}`
|
|
864
|
+
item.textContent = text
|
|
865
|
+
container.appendChild(item)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
advanceProgress(fileKey) {
|
|
869
|
+
// Optimistic progress: each completed test line moves the bar
|
|
870
|
+
// We don't know total count upfront, so we use a sqrt curve that
|
|
871
|
+
// approaches 95% and snaps to 100% on file_complete
|
|
872
|
+
const fill = document.getElementById(`dm-prog-${fileKey}`)
|
|
873
|
+
if (!fill) return
|
|
874
|
+
const current = parseFloat(fill.style.width) || 0
|
|
875
|
+
const next = Math.min(95, current + (95 - current) * 0.12)
|
|
876
|
+
fill.style.width = `${next.toFixed(1)}%`
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
setFileDot(fileKey, state) {
|
|
880
|
+
const dot = document.getElementById(`dm-dot-${fileKey}`)
|
|
881
|
+
if (!dot) return
|
|
882
|
+
dot.className = `dm-dot dm-dot--${state}`
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
setCoverageLabel(fileKey, pct) {
|
|
886
|
+
const el = document.getElementById(`dm-cov-${fileKey}`)
|
|
887
|
+
if (el) el.textContent = `${pct}%`
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
finalizePanel(fileKey, data) {
|
|
891
|
+
const status = data.status // "passed" | "failed"
|
|
892
|
+
this.setFileDot(fileKey, status)
|
|
893
|
+
|
|
894
|
+
const fill = document.getElementById(`dm-prog-${fileKey}`)
|
|
895
|
+
if (fill) {
|
|
896
|
+
fill.style.width = "100%"
|
|
897
|
+
fill.classList.toggle("dm-progress-fill--passed", status === "passed")
|
|
898
|
+
fill.classList.toggle("dm-progress-fill--failed", status === "failed")
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const meta = document.getElementById(`dm-meta-${fileKey}`)
|
|
902
|
+
if (meta) {
|
|
903
|
+
meta.innerHTML = this.metaBadges(data)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const logLink = document.getElementById(`dm-log-${fileKey}`)
|
|
907
|
+
if (logLink) logLink.style.display = "block"
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
metaBadges(data) {
|
|
911
|
+
const secs = ((data.duration_ms || 0) / 1000).toFixed(2)
|
|
912
|
+
let html = `<span class="dm-badge dm-badge--time">${secs}s</span>`
|
|
913
|
+
if (data.n1_count > 0) html += `<span class="dm-badge dm-badge--n1">${data.n1_count} N+1</span>`
|
|
914
|
+
if (data.slow_count > 0) html += `<span class="dm-badge dm-badge--slow">${data.slow_count} slow</span>`
|
|
915
|
+
if (data.coverage != null) html += `<span class="dm-badge dm-badge--cov">${data.coverage}% cov</span>`
|
|
916
|
+
return html
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── Summary stats ─────────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
updateSummaryStats() {
|
|
922
|
+
if (this.hasStatTotalTestsTarget)
|
|
923
|
+
this.statTotalTestsTarget.textContent = this.stats.tests
|
|
924
|
+
|
|
925
|
+
if (this.hasStatSlowQueriesTarget)
|
|
926
|
+
this.statSlowQueriesTarget.textContent = this.stats.slow
|
|
927
|
+
|
|
928
|
+
if (this.hasStatN1IssuesTarget)
|
|
929
|
+
this.statN1IssuesTarget.textContent = this.stats.n1
|
|
930
|
+
|
|
931
|
+
if (this.hasStatCoverageTarget && this.stats.coverageCount > 0) {
|
|
932
|
+
const avg = (this.stats.coverageSum / this.stats.coverageCount).toFixed(1)
|
|
933
|
+
this.statCoverageTarget.textContent = `${avg}%`
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── Teardown & utilities ──────────────────────────────────────────────────
|
|
938
|
+
|
|
939
|
+
resetState() {
|
|
940
|
+
this.teardownSubscriptions()
|
|
941
|
+
this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
|
|
942
|
+
this.fileListTarget.innerHTML = ""
|
|
943
|
+
this.updateSummaryStats()
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
teardownSubscriptions() {
|
|
947
|
+
Object.values(this.subscriptions).forEach(sub => sub?.unsubscribe())
|
|
948
|
+
this.subscriptions = {}
|
|
949
|
+
this.runSubscription?.unsubscribe()
|
|
950
|
+
this.runSubscription = null
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
get runTestsUrl() {
|
|
954
|
+
return `${window.location.origin}${this.element.closest("[data-devmetrics-mount]")
|
|
955
|
+
?.dataset.devmetricsMountPath || "/devmetrics"}/run_tests`
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
logDownloadUrl(runId, fileKey) {
|
|
959
|
+
return `/devmetrics/runs/${runId}/logs/${fileKey}/download`
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
get csrfToken() {
|
|
963
|
+
return document.querySelector("meta[name='csrf-token']")?.content || ""
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
### Step 5.3 — ActionCable consumer
|
|
969
|
+
|
|
970
|
+
**`app/javascript/devmetrics/channels/consumer.js`**
|
|
971
|
+
|
|
972
|
+
```javascript
|
|
973
|
+
import { createConsumer } from "@rails/actioncable"
|
|
974
|
+
export default createConsumer()
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
---
|
|
978
|
+
|
|
979
|
+
## Phase 6 — CSS
|
|
980
|
+
|
|
981
|
+
**`app/assets/stylesheets/devmetrics/dashboard.css`**
|
|
982
|
+
|
|
983
|
+
Minimal additions on top of your existing styles:
|
|
984
|
+
|
|
985
|
+
```css
|
|
986
|
+
/* File list */
|
|
987
|
+
.dm-file-row {
|
|
988
|
+
border-top: 1px solid #e5e7eb;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.dm-file-header {
|
|
992
|
+
display: flex;
|
|
993
|
+
align-items: center;
|
|
994
|
+
gap: 10px;
|
|
995
|
+
padding: 9px 16px;
|
|
996
|
+
cursor: pointer;
|
|
997
|
+
user-select: none;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.dm-file-header:hover {
|
|
1001
|
+
background: #f9fafb;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/* Status dots */
|
|
1005
|
+
.dm-dot {
|
|
1006
|
+
width: 8px;
|
|
1007
|
+
height: 8px;
|
|
1008
|
+
border-radius: 50%;
|
|
1009
|
+
flex-shrink: 0;
|
|
1010
|
+
}
|
|
1011
|
+
.dm-dot--pending { background: #d1d5db; }
|
|
1012
|
+
.dm-dot--running { background: #f59e0b; animation: dm-pulse 1s infinite; }
|
|
1013
|
+
.dm-dot--passed { background: #10b981; }
|
|
1014
|
+
.dm-dot--failed { background: #ef4444; }
|
|
1015
|
+
.dm-dot--error { background: #ef4444; }
|
|
1016
|
+
|
|
1017
|
+
@keyframes dm-pulse {
|
|
1018
|
+
0%, 100% { opacity: 1; }
|
|
1019
|
+
50% { opacity: 0.3; }
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* Chevron */
|
|
1023
|
+
.dm-chevron { font-size: 11px; color: #9ca3af; transition: transform 0.15s; }
|
|
1024
|
+
.dm-chevron--open { transform: rotate(90deg); }
|
|
1025
|
+
|
|
1026
|
+
/* Progress bar */
|
|
1027
|
+
.dm-progress-bar {
|
|
1028
|
+
height: 2px;
|
|
1029
|
+
background: #f3f4f6;
|
|
1030
|
+
}
|
|
1031
|
+
.dm-progress-fill {
|
|
1032
|
+
height: 100%;
|
|
1033
|
+
background: #f59e0b;
|
|
1034
|
+
transition: width 0.25s ease-out;
|
|
1035
|
+
}
|
|
1036
|
+
.dm-progress-fill--passed { background: #10b981; }
|
|
1037
|
+
.dm-progress-fill--failed { background: #ef4444; }
|
|
1038
|
+
|
|
1039
|
+
/* Panel (collapsed by default) */
|
|
1040
|
+
.dm-panel {
|
|
1041
|
+
display: none;
|
|
1042
|
+
border-top: 1px solid #e5e7eb;
|
|
1043
|
+
}
|
|
1044
|
+
.dm-panel--open {
|
|
1045
|
+
display: flex;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* Terminal pane */
|
|
1049
|
+
.dm-terminal {
|
|
1050
|
+
flex: 1;
|
|
1051
|
+
font-family: "SF Mono", "Fira Code", monospace;
|
|
1052
|
+
font-size: 11px;
|
|
1053
|
+
line-height: 1.65;
|
|
1054
|
+
padding: 10px 14px;
|
|
1055
|
+
max-height: 240px;
|
|
1056
|
+
overflow-y: auto;
|
|
1057
|
+
background: #0f172a;
|
|
1058
|
+
color: #94a3b8;
|
|
1059
|
+
}
|
|
1060
|
+
.dm-term-line--pass { color: #34d399; }
|
|
1061
|
+
.dm-term-line--fail { color: #f87171; }
|
|
1062
|
+
.dm-term-line--error { color: #f87171; }
|
|
1063
|
+
.dm-term-line--pending { color: #fbbf24; }
|
|
1064
|
+
.dm-term-line--summary { color: #e2e8f0; font-weight: 600; }
|
|
1065
|
+
|
|
1066
|
+
/* Sidebar */
|
|
1067
|
+
.dm-sidebar {
|
|
1068
|
+
width: 220px;
|
|
1069
|
+
border-left: 1px solid #1e293b;
|
|
1070
|
+
background: #0f172a;
|
|
1071
|
+
padding: 10px 12px;
|
|
1072
|
+
font-size: 11px;
|
|
1073
|
+
display: flex;
|
|
1074
|
+
flex-direction: column;
|
|
1075
|
+
gap: 12px;
|
|
1076
|
+
}
|
|
1077
|
+
.dm-sidebar-label {
|
|
1078
|
+
color: #475569;
|
|
1079
|
+
font-size: 10px;
|
|
1080
|
+
text-transform: uppercase;
|
|
1081
|
+
letter-spacing: 0.06em;
|
|
1082
|
+
margin-bottom: 4px;
|
|
1083
|
+
}
|
|
1084
|
+
.dm-sidebar-item {
|
|
1085
|
+
color: #94a3b8;
|
|
1086
|
+
padding: 2px 0;
|
|
1087
|
+
line-height: 1.5;
|
|
1088
|
+
word-break: break-word;
|
|
1089
|
+
}
|
|
1090
|
+
.dm-sidebar-item--slow { color: #fbbf24; }
|
|
1091
|
+
.dm-sidebar-item--n1 { color: #f87171; }
|
|
1092
|
+
.dm-sidebar-cov { color: #34d399; font-size: 14px; font-weight: 600; }
|
|
1093
|
+
|
|
1094
|
+
.dm-log-link {
|
|
1095
|
+
color: #60a5fa;
|
|
1096
|
+
font-size: 11px;
|
|
1097
|
+
text-decoration: none;
|
|
1098
|
+
}
|
|
1099
|
+
.dm-log-link:hover { text-decoration: underline; }
|
|
1100
|
+
|
|
1101
|
+
/* Badges */
|
|
1102
|
+
.dm-badge {
|
|
1103
|
+
font-size: 11px;
|
|
1104
|
+
padding: 2px 6px;
|
|
1105
|
+
border-radius: 10px;
|
|
1106
|
+
margin-left: 4px;
|
|
1107
|
+
}
|
|
1108
|
+
.dm-badge--time { background: #f3f4f6; color: #6b7280; }
|
|
1109
|
+
.dm-badge--n1 { background: #fee2e2; color: #dc2626; }
|
|
1110
|
+
.dm-badge--slow { background: #fef3c7; color: #d97706; }
|
|
1111
|
+
.dm-badge--cov { background: #d1fae5; color: #065f46; }
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
---
|
|
1115
|
+
|
|
1116
|
+
## Phase 7 — Solid Cable & Queue Config
|
|
1117
|
+
|
|
1118
|
+
### Step 7.1 — Solid Cable (already installed if following README)
|
|
1119
|
+
|
|
1120
|
+
Ensure `config/cable.yml`:
|
|
1121
|
+
|
|
1122
|
+
```yaml
|
|
1123
|
+
development:
|
|
1124
|
+
adapter: solid_cable
|
|
1125
|
+
polling_interval: 0.1.seconds
|
|
1126
|
+
message_retention: 1.day
|
|
1127
|
+
|
|
1128
|
+
production:
|
|
1129
|
+
adapter: solid_cable
|
|
1130
|
+
polling_interval: 0.5.seconds
|
|
1131
|
+
message_retention: 1.week
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
### Step 7.2 — ActiveJob concurrency for parallel jobs
|
|
1135
|
+
|
|
1136
|
+
For Solid Queue (Rails 8 / recommended):
|
|
1137
|
+
|
|
1138
|
+
```yaml
|
|
1139
|
+
# config/queue.yml (Solid Queue)
|
|
1140
|
+
default: &default
|
|
1141
|
+
workers:
|
|
1142
|
+
- queues: [devmetrics]
|
|
1143
|
+
threads: 10 # allow up to 10 parallel file jobs
|
|
1144
|
+
processes: 1
|
|
1145
|
+
|
|
1146
|
+
development:
|
|
1147
|
+
<<: *default
|
|
1148
|
+
|
|
1149
|
+
production:
|
|
1150
|
+
<<: *default
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
For Sidekiq, add to `config/sidekiq.yml`:
|
|
1154
|
+
|
|
1155
|
+
```yaml
|
|
1156
|
+
:queues:
|
|
1157
|
+
- [devmetrics, 5]
|
|
1158
|
+
:concurrency: 10
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
## Phase 8 — Generator Updates
|
|
1164
|
+
|
|
1165
|
+
Update `lib/devmetrics/generators/install_generator.rb` to also:
|
|
1166
|
+
|
|
1167
|
+
1. Copy the two new migrations
|
|
1168
|
+
2. Add `require "devmetrics/models/run"` and `require "devmetrics/models/file_result"` to the engine autoload
|
|
1169
|
+
3. Print post-install notice about queue concurrency requirement
|
|
1170
|
+
|
|
1171
|
+
---
|
|
1172
|
+
|
|
1173
|
+
## File Checklist
|
|
1174
|
+
|
|
1175
|
+
| File | Action |
|
|
1176
|
+
|------|--------|
|
|
1177
|
+
| `db/migrate/..._create_devmetrics_runs.rb` | Create |
|
|
1178
|
+
| `db/migrate/..._create_devmetrics_file_results.rb` | Create |
|
|
1179
|
+
| `lib/devmetrics/models/run.rb` | Create |
|
|
1180
|
+
| `lib/devmetrics/models/file_result.rb` | Create |
|
|
1181
|
+
| `lib/devmetrics/log_writer.rb` | Create |
|
|
1182
|
+
| `lib/devmetrics/sql_instrumentor.rb` | Create (replace inline subscriber) |
|
|
1183
|
+
| `lib/devmetrics/run_orchestrator.rb` | Create |
|
|
1184
|
+
| `lib/devmetrics/jobs/file_runner_job.rb` | Create (replaces `performance_test_runner_job.rb`) |
|
|
1185
|
+
| `lib/devmetrics/engine.rb` | Modify — move SQL subscriber here, add model autoloads |
|
|
1186
|
+
| `app/channels/devmetrics/metrics_channel.rb` | Modify — add stream_type routing |
|
|
1187
|
+
| `app/controllers/devmetrics/metrics_controller.rb` | Modify — update `run_tests`, add `run_status`, `download_log` |
|
|
1188
|
+
| `config/routes.rb` (engine) | Modify — add new routes |
|
|
1189
|
+
| `app/views/devmetrics/metrics/index.html.erb` | Modify — add data targets |
|
|
1190
|
+
| `app/javascript/devmetrics/controllers/metrics_controller.js` | Rewrite |
|
|
1191
|
+
| `app/javascript/devmetrics/channels/consumer.js` | Create |
|
|
1192
|
+
| `app/assets/stylesheets/devmetrics/dashboard.css` | Modify — add panel/terminal/sidebar styles |
|
|
1193
|
+
|
|
1194
|
+
---
|
|
1195
|
+
|
|
1196
|
+
## Implementation Order for Claude Code
|
|
1197
|
+
|
|
1198
|
+
Run these phases in sequence. Each phase is independently testable.
|
|
1199
|
+
|
|
1200
|
+
1. **Phase 1** — migrations + models → `rails db:migrate` → verify tables exist
|
|
1201
|
+
2. **Phase 2** — `LogWriter`, `SqlInstrumentor`, `RunOrchestrator`, `FileRunnerJob` → unit test each class
|
|
1202
|
+
3. **Phase 3** — `MetricsChannel` update → test via `rails console`: `ActionCable.server.broadcast(...)`
|
|
1203
|
+
4. **Phase 4** — controller + routes → `curl -X POST /devmetrics/run_tests` → verify JSON response + job enqueued
|
|
1204
|
+
5. **Phase 5** — Stimulus controller + consumer.js → open `/devmetrics`, run tests, watch panels appear
|
|
1205
|
+
6. **Phase 6** — CSS → visual polish
|
|
1206
|
+
7. **Phase 7** — queue concurrency config → verify multiple jobs actually run in parallel (`bin/jobs` or Sidekiq UI)
|
|
1207
|
+
8. **Phase 8** — generator updates → test fresh install on a blank Rails app
|
|
1208
|
+
|
|
1209
|
+
---
|
|
1210
|
+
|
|
1211
|
+
## Testing
|
|
1212
|
+
|
|
1213
|
+
```bash
|
|
1214
|
+
# Unit tests
|
|
1215
|
+
bundle exec rspec spec/lib/devmetrics/run_orchestrator_spec.rb
|
|
1216
|
+
bundle exec rspec spec/lib/devmetrics/jobs/file_runner_job_spec.rb
|
|
1217
|
+
bundle exec rspec spec/lib/devmetrics/sql_instrumentor_spec.rb
|
|
1218
|
+
|
|
1219
|
+
# Integration: start server + run via curl
|
|
1220
|
+
bin/dev &
|
|
1221
|
+
curl -s -X POST http://localhost:3000/devmetrics/run_tests \
|
|
1222
|
+
-H "Content-Type: application/json" | jq .
|
|
1223
|
+
|
|
1224
|
+
# Check logs were written
|
|
1225
|
+
ls -la log/devmetrics/runs/
|
|
1226
|
+
|
|
1227
|
+
# Check DB records
|
|
1228
|
+
rails runner "puts ::Devmetrics::Run.last.inspect"
|
|
1229
|
+
rails runner "puts ::Devmetrics::FileResult.all.map(&:status).inspect"
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1234
|
+
## Known Gotchas
|
|
1235
|
+
|
|
1236
|
+
**Thread isolation for SQL**: `Thread.current[THREAD_KEY]` works correctly with Puma and Solid Queue (each job runs in its own thread). With Falcon (fiber-based), replace with a Fiber-local key using `Fiber[:devmetrics_sql_collector]`.
|
|
1237
|
+
|
|
1238
|
+
**SimpleCov interference**: If the host app already runs SimpleCov, calling it inside a subprocess is fine — subprocesses have isolated Ruby state. The host app's coverage is unaffected.
|
|
1239
|
+
|
|
1240
|
+
**Open3 + bundler**: Always run `bundle exec rspec` inside `chdir: Rails.root` so the gem's own Gemfile doesn't shadow the host app's.
|
|
1241
|
+
|
|
1242
|
+
**Progress bar**: Because we don't know total test count before the run, the progress bar uses an asymptotic curve (each step covers 12% of remaining distance, capping at 95%). It snaps to 100% on `file_complete`. If you want an accurate bar, add `--dry-run` before the real run to get the count, but this doubles wall time.
|
|
1243
|
+
|
|
1244
|
+
**Log rotation**: `log/devmetrics/runs/` is not cleaned up automatically. Add a rake task `devmetrics:clean_logs[days=7]` (not in scope of this plan) or rely on `logrotate`.
|