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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +205 -0
  3. data/app/assets/builds/tailwind.css +2 -0
  4. data/app/assets/images/icon.png +0 -0
  5. data/app/assets/images/icon.svg +68 -0
  6. data/app/assets/stylesheets/devmetrics/dashboard.css +476 -0
  7. data/app/assets/stylesheets/devmetrics_live/application.css +10 -0
  8. data/app/assets/tailwind/application.css +1 -0
  9. data/app/channels/application_cable/channel.rb +4 -0
  10. data/app/channels/application_cable/connection.rb +4 -0
  11. data/app/channels/devformance/metrics_channel.rb +25 -0
  12. data/app/controllers/application_controller.rb +4 -0
  13. data/app/controllers/devformance/application_controller.rb +19 -0
  14. data/app/controllers/devformance/icons_controller.rb +21 -0
  15. data/app/controllers/devformance/metrics_controller.rb +41 -0
  16. data/app/controllers/devformance/playground_controller.rb +89 -0
  17. data/app/helpers/application_helper.rb +9 -0
  18. data/app/helpers/metrics_helper.rb +2 -0
  19. data/app/helpers/playground_helper.rb +2 -0
  20. data/app/javascript/devformance/channels/consumer.js +2 -0
  21. data/app/javascript/devformance/channels/index.js +1 -0
  22. data/app/javascript/devformance/controllers/application.js +9 -0
  23. data/app/javascript/devformance/controllers/hello_controller.js +7 -0
  24. data/app/javascript/devformance/controllers/index.js +14 -0
  25. data/app/javascript/devformance/controllers/metrics_controller.js +364 -0
  26. data/app/javascript/devformance/controllers/playground_controller.js +33 -0
  27. data/app/javascript/devmetrics.js +4 -0
  28. data/app/jobs/application_job.rb +7 -0
  29. data/app/jobs/devformance/file_runner_job.rb +318 -0
  30. data/app/mailers/application_mailer.rb +4 -0
  31. data/app/models/application_record.rb +3 -0
  32. data/app/models/devformance/file_result.rb +14 -0
  33. data/app/models/devformance/run.rb +19 -0
  34. data/app/models/devformance/slow_query.rb +5 -0
  35. data/app/views/devformance/metrics/index.html.erb +79 -0
  36. data/app/views/devformance/playground/run.html.erb +63 -0
  37. data/app/views/layouts/devformance/application.html.erb +856 -0
  38. data/app/views/layouts/mailer.html.erb +13 -0
  39. data/app/views/layouts/mailer.text.erb +1 -0
  40. data/app/views/metrics/index.html.erb +334 -0
  41. data/app/views/pwa/manifest.json.erb +22 -0
  42. data/app/views/pwa/service-worker.js +26 -0
  43. data/config/BUSINESS_LOGIC_PLAN.md +1244 -0
  44. data/config/application.rb +31 -0
  45. data/config/boot.rb +4 -0
  46. data/config/cable.yml +17 -0
  47. data/config/cache.yml +16 -0
  48. data/config/credentials.yml.enc +1 -0
  49. data/config/database.yml +98 -0
  50. data/config/deploy.yml +116 -0
  51. data/config/engine_routes.rb +13 -0
  52. data/config/environment.rb +5 -0
  53. data/config/environments/development.rb +84 -0
  54. data/config/environments/production.rb +90 -0
  55. data/config/environments/test.rb +59 -0
  56. data/config/importmap.rb +11 -0
  57. data/config/initializers/assets.rb +7 -0
  58. data/config/initializers/content_security_policy.rb +25 -0
  59. data/config/initializers/filter_parameter_logging.rb +8 -0
  60. data/config/initializers/inflections.rb +16 -0
  61. data/config/locales/en.yml +31 -0
  62. data/config/master.key +1 -0
  63. data/config/puma.rb +41 -0
  64. data/config/queue.yml +22 -0
  65. data/config/recurring.yml +15 -0
  66. data/config/routes.rb +20 -0
  67. data/config/storage.yml +34 -0
  68. data/db/migrate/20260317144616_create_slow_queries.rb +13 -0
  69. data/db/migrate/20260317175630_create_performance_runs.rb +14 -0
  70. data/db/migrate/20260317195043_add_run_id_to_slow_queries.rb +10 -0
  71. data/db/migrate/20260319000001_create_devformance_runs.rb +20 -0
  72. data/db/migrate/20260319000002_create_devformance_file_results.rb +29 -0
  73. data/db/migrate/20260319000003_add_columns_to_slow_queries.rb +7 -0
  74. data/lib/devformance/bullet_log_parser.rb +47 -0
  75. data/lib/devformance/compatibility.rb +12 -0
  76. data/lib/devformance/coverage_setup.rb +33 -0
  77. data/lib/devformance/engine.rb +80 -0
  78. data/lib/devformance/log_writer.rb +29 -0
  79. data/lib/devformance/run_orchestrator.rb +58 -0
  80. data/lib/devformance/sql_instrumentor.rb +29 -0
  81. data/lib/devformance/test_framework/base.rb +43 -0
  82. data/lib/devformance/test_framework/coverage_helper.rb +76 -0
  83. data/lib/devformance/test_framework/detector.rb +26 -0
  84. data/lib/devformance/test_framework/minitest.rb +71 -0
  85. data/lib/devformance/test_framework/registry.rb +24 -0
  86. data/lib/devformance/test_framework/rspec.rb +60 -0
  87. data/lib/devformance/test_helper.rb +42 -0
  88. data/lib/devformance/version.rb +3 -0
  89. data/lib/devformance.rb +196 -0
  90. data/lib/generators/devformance/install/install_generator.rb +73 -0
  91. data/lib/generators/devformance/install/templates/add_columns_to_slow_queries.rb.erb +7 -0
  92. data/lib/generators/devformance/install/templates/add_run_id_to_slow_queries.rb.erb +10 -0
  93. data/lib/generators/devformance/install/templates/create_devformance_file_results.rb.erb +29 -0
  94. data/lib/generators/devformance/install/templates/create_devformance_runs.rb.erb +20 -0
  95. data/lib/generators/devformance/install/templates/create_performance_runs.rb.erb +14 -0
  96. data/lib/generators/devformance/install/templates/create_slow_queries.rb.erb +13 -0
  97. data/lib/generators/devformance/install/templates/initializer.rb +23 -0
  98. data/lib/tasks/devformance.rake +45 -0
  99. data/spec/fixtures/devformance/devformance_run.rb +27 -0
  100. data/spec/fixtures/devformance/file_result.rb +34 -0
  101. data/spec/fixtures/devformance/slow_query.rb +11 -0
  102. data/spec/lib/devmetrics/log_writer_spec.rb +81 -0
  103. data/spec/lib/devmetrics/run_orchestrator_spec.rb +102 -0
  104. data/spec/lib/devmetrics/sql_instrumentor_spec.rb +115 -0
  105. data/spec/models/devmetrics/file_result_spec.rb +87 -0
  106. data/spec/models/devmetrics/run_spec.rb +66 -0
  107. data/spec/models/query_log_spec.rb +21 -0
  108. data/spec/rails_helper.rb +20 -0
  109. data/spec/requests/devmetrics/metrics_controller_spec.rb +149 -0
  110. data/spec/requests/devmetrics_pages_spec.rb +12 -0
  111. data/spec/requests/performance_spec.rb +17 -0
  112. data/spec/requests/slow_perf_spec.rb +9 -0
  113. data/spec/spec_helper.rb +114 -0
  114. data/spec/support/devmetrics_formatter.rb +106 -0
  115. data/spec/support/devmetrics_metrics.rb +37 -0
  116. data/spec/support/factory_bot.rb +3 -0
  117. metadata +200 -0
data/config/puma.rb ADDED
@@ -0,0 +1,41 @@
1
+ # This configuration file will be evaluated by Puma. The top-level methods that
2
+ # are invoked here are part of Puma's configuration DSL. For more information
3
+ # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
4
+ #
5
+ # Puma starts a configurable number of processes (workers) and each process
6
+ # serves each request in a thread from an internal thread pool.
7
+ #
8
+ # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
9
+ # should only set this value when you want to run 2 or more workers. The
10
+ # default is already 1.
11
+ #
12
+ # The ideal number of threads per worker depends both on how much time the
13
+ # application spends waiting for IO operations and on how much you wish to
14
+ # prioritize throughput over latency.
15
+ #
16
+ # As a rule of thumb, increasing the number of threads will increase how much
17
+ # traffic a given process can handle (throughput), but due to CRuby's
18
+ # Global VM Lock (GVL) it has diminishing returns and will degrade the
19
+ # response time (latency) of the application.
20
+ #
21
+ # The default is set to 3 threads as it's deemed a decent compromise between
22
+ # throughput and latency for the average Rails application.
23
+ #
24
+ # Any libraries that use a connection pool or another resource pool should
25
+ # be configured to provide at least as many connections as the number of
26
+ # threads. This includes Active Record's `pool` parameter in `database.yml`.
27
+ threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
28
+ threads threads_count, threads_count
29
+
30
+ # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
31
+ port ENV.fetch("PORT", 3000)
32
+
33
+ # Allow puma to be restarted by `bin/rails restart` command.
34
+ plugin :tmp_restart
35
+
36
+ # Run the Solid Queue supervisor inside of Puma for single-server deployments
37
+ plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
38
+
39
+ # Specify the PID file. Defaults to tmp/pids/server.pid in development.
40
+ # In other environments, only set the PID file if requested.
41
+ pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
data/config/queue.yml ADDED
@@ -0,0 +1,22 @@
1
+ default: &default
2
+ dispatchers:
3
+ - polling_interval: 1
4
+ batch_size: 500
5
+ workers:
6
+ - queues: [devformance]
7
+ threads: 10
8
+ processes: 1
9
+ polling_interval: 0.1
10
+ - queues: "*"
11
+ threads: 3
12
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
13
+ polling_interval: 0.1
14
+
15
+ development:
16
+ <<: *default
17
+
18
+ test:
19
+ <<: *default
20
+
21
+ production:
22
+ <<: *default
@@ -0,0 +1,15 @@
1
+ # examples:
2
+ # periodic_cleanup:
3
+ # class: CleanSoftDeletedRecordsJob
4
+ # queue: background
5
+ # args: [ 1000, { batch_size: 500 } ]
6
+ # schedule: every hour
7
+ # periodic_cleanup_with_command:
8
+ # command: "SoftDeletedRecord.due.delete_all"
9
+ # priority: 2
10
+ # schedule: at 5am every day
11
+
12
+ production:
13
+ clear_solid_queue_finished_jobs:
14
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
15
+ schedule: every hour at minute 12
data/config/routes.rb ADDED
@@ -0,0 +1,20 @@
1
+ Rails.application.routes.draw do
2
+ scope "/devformance", module: "devformance", as: "devformance" do
3
+ root "metrics#index"
4
+
5
+ post "run_tests", to: "metrics#run_tests"
6
+ get "runs/:run_id/status", to: "metrics#run_status"
7
+ get "runs/:run_id/logs/:file_key/download", to: "metrics#download_log"
8
+
9
+ get "playground", to: "playground#run"
10
+ post "playground/run", to: "playground#run"
11
+
12
+ get "icon.svg", to: "icons#svg"
13
+ get "icon.png", to: "icons#png"
14
+ end
15
+
16
+ get "/", to: redirect("/devformance")
17
+ get "/up", to: "rails/health#show"
18
+
19
+ mount ActionCable.server => "/devformance/cable"
20
+ end
@@ -0,0 +1,34 @@
1
+ test:
2
+ service: Disk
3
+ root: <%= Rails.root.join("tmp/storage") %>
4
+
5
+ local:
6
+ service: Disk
7
+ root: <%= Rails.root.join("storage") %>
8
+
9
+ # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10
+ # amazon:
11
+ # service: S3
12
+ # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13
+ # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14
+ # region: us-east-1
15
+ # bucket: your_own_bucket-<%= Rails.env %>
16
+
17
+ # Remember not to checkin your GCS keyfile to a repository
18
+ # google:
19
+ # service: GCS
20
+ # project: your_project
21
+ # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22
+ # bucket: your_own_bucket-<%= Rails.env %>
23
+
24
+ # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25
+ # microsoft:
26
+ # service: AzureStorage
27
+ # storage_account_name: your_account_name
28
+ # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29
+ # container: your_container_name-<%= Rails.env %>
30
+
31
+ # mirror:
32
+ # service: Mirror
33
+ # primary: local
34
+ # mirrors: [ amazon, google, microsoft ]
@@ -0,0 +1,13 @@
1
+ class CreateSlowQueries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ unless table_exists?(:slow_queries)
4
+ create_table :slow_queries do |t|
5
+ t.string :model_class
6
+ t.integer :line_number
7
+ t.text :fix_suggestion
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ class CreatePerformanceRuns < ActiveRecord::Migration[7.2]
2
+ def change
3
+ unless table_exists?(:performance_runs)
4
+ create_table :performance_runs do |t|
5
+ t.string :run_id
6
+ t.integer :total_files
7
+ t.integer :completed_files
8
+ t.string :status
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ class AddRunIdToSlowQueries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ unless column_exists?(:slow_queries, :run_id)
4
+ add_column :slow_queries, :run_id, :string
5
+ end
6
+ unless index_exists?(:slow_queries, :run_id)
7
+ add_index :slow_queries, :run_id
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ class CreateDevformanceRuns < ActiveRecord::Migration[7.2]
2
+ def change
3
+ unless table_exists?(:devformance_runs)
4
+ create_table :devformance_runs do |t|
5
+ t.string :run_id, null: false
6
+ t.integer :status, default: 0, null: false
7
+ t.datetime :started_at
8
+ t.datetime :finished_at
9
+ t.integer :total_files, default: 0
10
+ t.integer :completed_files, default: 0
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ unless index_exists?(:devformance_runs, :run_id)
16
+ add_index :devformance_runs, :run_id, unique: true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ class CreateDevformanceFileResults < ActiveRecord::Migration[7.2]
2
+ def change
3
+ unless table_exists?(:devformance_file_results)
4
+ create_table :devformance_file_results do |t|
5
+ t.string :run_id, null: false
6
+ t.string :file_key, null: false
7
+ t.string :file_path
8
+ t.integer :status, default: 0, null: false
9
+ t.integer :total_tests, default: 0
10
+ t.integer :passed_tests, default: 0
11
+ t.integer :failed_tests, default: 0
12
+ t.integer :slow_query_count, default: 0
13
+ t.integer :n1_count, default: 0
14
+ t.float :coverage
15
+ t.integer :duration_ms
16
+ t.string :log_path
17
+
18
+ t.timestamps
19
+ end
20
+
21
+ unless index_exists?(:devformance_file_results, :run_id)
22
+ add_index :devformance_file_results, :run_id
23
+ end
24
+ unless index_exists?(:devformance_file_results, [ :run_id, :file_key ])
25
+ add_index :devformance_file_results, [ :run_id, :file_key ], unique: true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ class AddColumnsToSlowQueries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :slow_queries, :sql, :text unless column_exists?(:slow_queries, :sql)
4
+ add_column :slow_queries, :duration_ms, :float unless column_exists?(:slow_queries, :duration_ms)
5
+ add_column :slow_queries, :file_key, :string unless column_exists?(:slow_queries, :file_key)
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ module Devformance
2
+ module BulletLogParser
3
+ Warning = Data.define(:type, :endpoint, :model_class, :associations, :fix_suggestion, :line_number, :call_stack)
4
+
5
+ BLOCK_START = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\[WARN\]/
6
+
7
+ def self.parse(content)
8
+ blocks = content.split(BLOCK_START).map(&:strip).reject(&:empty?)
9
+ blocks.filter_map { |block| parse_block(block) }
10
+ end
11
+
12
+ def self.parse_block(block)
13
+ lines = block.lines.map(&:chomp)
14
+
15
+ endpoint = lines.find { |l| l.match?(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+/) }&.strip
16
+ type_line = lines.find { |l| l.include?("eager loading detected") }
17
+ model_line = lines.find { |l| l.match?(/\w+ => \[/) }
18
+ fix_line = lines.find { |l| l.include?("Add to your query") || l.include?("Remove from your query") }
19
+
20
+ return nil unless type_line && model_line
21
+
22
+ type = type_line.strip.start_with?("USE") ? :add_eager_load : :remove_eager_load
23
+
24
+ model_class = model_line.match(/^\s*(\w+)\s*=>/)[1] rescue nil
25
+ associations = model_line.match(/=>\s*(\[.+\])/)[1] rescue nil
26
+
27
+ fix_suggestion = fix_line&.strip
28
+
29
+ call_stack_start = lines.index { |l| l.strip == "Call stack" }
30
+ call_stack = call_stack_start ? lines[(call_stack_start + 1)..].map(&:strip).reject(&:empty?) : []
31
+
32
+ app_line = call_stack.find { |l| !l.include?("/gems/") && !l.include?("/ruby/") && l.match?(/\.rb:\d+/) }
33
+ line_number = app_line&.match(/:(\d+):/)&.[](1)&.to_i
34
+
35
+ Warning.new(
36
+ type: type,
37
+ endpoint: endpoint,
38
+ model_class: model_class,
39
+ associations: associations,
40
+ fix_suggestion: fix_suggestion,
41
+ line_number: line_number,
42
+ call_stack: call_stack
43
+ )
44
+ end
45
+ private_class_method :parse_block
46
+ end
47
+ end
@@ -0,0 +1,12 @@
1
+ module Devformance
2
+ module Compatibility
3
+ def self.importmap? = defined?(::Importmap::Engine)
4
+ def self.turbo? = defined?(::Turbo::Engine)
5
+ def self.stimulus? = defined?(::Stimulus::Engine)
6
+ def self.propshaft? = defined?(::Propshaft::Engine)
7
+ def self.bullet? = defined?(::Bullet)
8
+ def self.solid_cable? = defined?(::SolidCable)
9
+ def self.hotwire? = turbo? && stimulus? && importmap?
10
+ def self.rails_version = Gem::Version.new(Rails.version)
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ require "simplecov"
2
+
3
+ module Devformance
4
+ class CoverageSetup
5
+ def self.start
6
+ return unless ENV["COVERAGE"] == "true" || ENV["SIMPLECOV"] == "true"
7
+
8
+ SimpleCov.start do
9
+ add_filter "/vendor/"
10
+ add_filter "/spec/"
11
+
12
+ unless ENV["DEVMETRICS_INCLUDE_TESTS"] == "true"
13
+ add_filter "/test/"
14
+ end
15
+
16
+ add_filter "/config/"
17
+ add_filter "/db/"
18
+
19
+ minimum_coverage Devformance.configuration.coverage_minimum_coverage || 80
20
+
21
+ coverage_dir Devformance.configuration.coverage_dir || "coverage"
22
+ end
23
+ end
24
+
25
+ def self.result
26
+ SimpleCov.result
27
+ end
28
+
29
+ def self.pct
30
+ SimpleCov.result&.covered_percent&.round(1)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,80 @@
1
+ module Devformance
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Devformance
4
+
5
+ config.static_assets = true
6
+
7
+ # Use a dedicated routes file so config/routes.rb can be the host-app routes
8
+ paths["config/routes.rb"] = "config/engine_routes.rb"
9
+
10
+ config.to_prepare do
11
+ Devformance::ApplicationController.layout "devformance/application"
12
+
13
+ require "devformance/log_writer"
14
+ require "devformance/sql_instrumentor"
15
+ require "devformance/run_orchestrator"
16
+ require "devformance/bullet_log_parser"
17
+
18
+ # Explicitly require and alias channel for ActionCable/Solid Cable
19
+ # ActionCable uses constantize which needs the class to be loadable
20
+ begin
21
+ require "devformance/metrics_channel"
22
+ rescue LoadError
23
+ # Already loaded via autoload
24
+ end
25
+
26
+ unless ::Object.const_defined?(:DevformanceChannel)
27
+ ::Object.const_set(:DevformanceChannel, Devformance::MetricsChannel)
28
+ end
29
+ unless ::Object.const_defined?(:MetricsChannel)
30
+ ::Object.const_set(:MetricsChannel, Devformance::MetricsChannel)
31
+ end
32
+ end
33
+
34
+ # ── Asset & View configuration ───────────────────────────────────────────
35
+ initializer "devformance.importmap", after: "importmap" do |app|
36
+ if app.config.respond_to?(:importmap)
37
+ app.config.importmap.paths << root.join("config/importmap.rb")
38
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
39
+ end
40
+ end
41
+
42
+ # ── Action View Helpers ──────────────────────────────────────────────────
43
+ initializer "devformance.helpers" do
44
+ ActiveSupport.on_load(:action_view) do
45
+ if defined?(::Importmap::ImportmapTagsHelper)
46
+ include ::Importmap::ImportmapTagsHelper
47
+ end
48
+
49
+ if defined?(::Turbo::FramesHelper)
50
+ include ::Turbo::FramesHelper
51
+ end
52
+ end
53
+ end
54
+
55
+ initializer "devformance.assets" do |app|
56
+ if app.config.respond_to?(:assets)
57
+ app.config.assets.paths << root.join("app/assets/stylesheets")
58
+ app.config.assets.paths << root.join("app/assets/javascripts")
59
+ app.config.assets.paths << root.join("app/assets/images")
60
+ app.config.assets.paths << root.join("app/javascript")
61
+ unless defined?(::Propshaft)
62
+ app.config.assets.precompile += %w[devformance/application.js devformance/dashboard.css icon.svg icon.png]
63
+ end
64
+ end
65
+ end
66
+
67
+ initializer "devformance.sql_notifications" do
68
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
69
+ event = ActiveSupport::Notifications::Event.new(*args)
70
+ Devformance::SqlInstrumentor.record(event) if defined?(Devformance::SqlInstrumentor)
71
+ end
72
+ end
73
+
74
+ config.after_initialize do
75
+ if ENV["DEVMETRICS_SKIP_DB_SETUP"] == "1"
76
+ Rails.logger.info "[Devformance] Database setup skipped for test runs"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,29 @@
1
+ module Devformance
2
+ class LogWriter
3
+ LOG_BASE = -> { Rails.root.join("log", "devformance", "runs") }
4
+
5
+ def self.open(run_id, file_key)
6
+ dir = LOG_BASE.call.join(run_id.to_s)
7
+ FileUtils.mkdir_p(dir)
8
+ new(dir.join("#{file_key}.log"))
9
+ end
10
+
11
+ def initialize(path)
12
+ @path = path
13
+ @file = File.open(path, "w")
14
+ end
15
+
16
+ def write(line)
17
+ @file.puts(line)
18
+ @file.flush
19
+ end
20
+
21
+ def close
22
+ @file.close
23
+ end
24
+
25
+ def path
26
+ @path.to_s
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,58 @@
1
+ module Devformance
2
+ class RunOrchestrator
3
+ def self.call(framework: nil)
4
+ new(framework: framework).call
5
+ end
6
+
7
+ def initialize(framework: nil)
8
+ @framework = framework || detect_framework
9
+ end
10
+
11
+ def call
12
+ file_paths = @framework.discover_files
13
+ if file_paths.empty?
14
+ return { error: "No test files found matching #{@framework.file_pattern}" }
15
+ end
16
+
17
+ tagged = file_paths.select { |f| File.read(f).match?(/devformance/i) }
18
+ file_paths = tagged if tagged.any?
19
+
20
+ run = ::Devformance::Run.create_for_files(file_paths)
21
+
22
+ file_metas = file_paths.map do |path|
23
+ file_key = ::Devformance::FileResult.file_key_for(path)
24
+ ::Devformance::FileResult.create!(
25
+ run_id: run.run_id,
26
+ file_key: file_key,
27
+ file_path: path,
28
+ status: :pending
29
+ )
30
+ { file_key: file_key, file_path: path, display_name: path.sub(Rails.root.to_s + "/", "") }
31
+ end
32
+
33
+ ActionCable.server.broadcast(
34
+ "devformance:run:#{run.run_id}",
35
+ { type: "run_started", run_id: run.run_id, files: file_metas, framework: @framework.name }
36
+ )
37
+
38
+ file_metas.each do |meta|
39
+ ::Devformance::FileRunnerJob.perform_later(
40
+ run_id: run.run_id,
41
+ file_path: meta[:file_path],
42
+ file_key: meta[:file_key],
43
+ framework: @framework.name
44
+ )
45
+ end
46
+
47
+ { run_id: run.run_id, file_count: file_metas.size, files: file_metas, framework: @framework.name }
48
+ end
49
+
50
+ private
51
+
52
+ def detect_framework
53
+ Devformance.configuration.framework_adapter ||
54
+ Devformance::TestFramework::Detector.detect(Rails.root) ||
55
+ Devformance::TestFramework::RSpec.new(Rails.root)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ module Devformance
2
+ class SqlInstrumentor
3
+ THREAD_KEY = :devformance_sql_collector
4
+
5
+ def self.around_run
6
+ Thread.current[THREAD_KEY] = { queries: [], start: Time.current }
7
+ yield
8
+ ensure
9
+ Thread.current[THREAD_KEY] = nil
10
+ end
11
+
12
+ def self.record(event)
13
+ collector = Thread.current[THREAD_KEY]
14
+ return unless collector
15
+
16
+ ms = event.duration.round(2)
17
+ sql = event.payload[:sql].to_s.strip
18
+
19
+ return if sql.match?(/\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i)
20
+
21
+ collector[:queries] << { sql: sql, ms: ms, at: Time.current.iso8601 }
22
+ collector[:queries].last
23
+ end
24
+
25
+ def self.queries
26
+ Thread.current[THREAD_KEY]&.dig(:queries) || []
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ module Devformance
2
+ module TestFramework
3
+ class Base
4
+ attr_reader :root_path
5
+
6
+ def initialize(root_path)
7
+ @root_path = root_path
8
+ end
9
+
10
+ def discover_files
11
+ raise NotImplementedError, "#{self.class} must implement #discover_files"
12
+ end
13
+
14
+ def run_command(file_path)
15
+ raise NotImplementedError, "#{self.class} must implement #run_command"
16
+ end
17
+
18
+ def parse_summary(output)
19
+ raise NotImplementedError, "#{self.class} must implement #parse_summary"
20
+ end
21
+
22
+ def classify_line(line)
23
+ raise NotImplementedError, "#{self.class} must implement #classify_line"
24
+ end
25
+
26
+ def file_pattern
27
+ raise NotImplementedError, "#{self.class} must implement #file_pattern"
28
+ end
29
+
30
+ def name
31
+ raise NotImplementedError, "#{self.class} must implement #name"
32
+ end
33
+
34
+ def available?
35
+ !discover_files.empty?
36
+ end
37
+
38
+ def runner_command
39
+ raise NotImplementedError, "#{self.class} must implement #runner_command"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,76 @@
1
+ module Devformance
2
+ module TestFramework
3
+ module CoverageHelper
4
+ def self.setup_simplecov
5
+ return unless defined?(SimpleCov)
6
+
7
+ SimpleCov.start do
8
+ add_filter "/vendor/"
9
+ add_filter "/spec/"
10
+ add_filter "/test/"
11
+ add_filter "/config/"
12
+ add_filter "/db/"
13
+
14
+ minimum_coverage Devformance.configuration.coverage_minimum_coverage || 80
15
+ end
16
+ end
17
+
18
+ def self.run_with_coverage(file_path, framework_name)
19
+ if framework_name == "RSpec"
20
+ run_rspec_with_coverage(file_path)
21
+ elsif framework_name == "Minitest"
22
+ run_minitest_with_coverage(file_path)
23
+ else
24
+ raise "Unsupported framework: #{framework_name}"
25
+ end
26
+ end
27
+
28
+ def self.run_rspec_with_coverage(file_path)
29
+ require "simplecov"
30
+ SimpleCov.start do
31
+ add_filter "/vendor/"
32
+ add_filter "/spec/"
33
+ end
34
+
35
+ require "rspec/core"
36
+ exit_code = RSpec::Core::Runner.run([ file_path, "--format", "progress" ])
37
+ { exit_code: exit_code, coverage: SimpleCov.result }
38
+ end
39
+
40
+ def self.run_minitest_with_coverage(file_path)
41
+ require "simplecov"
42
+ SimpleCov.start do
43
+ add_filter "/vendor/"
44
+ add_filter "/test/"
45
+ end
46
+
47
+ require "rails/test_help"
48
+ require "minitest/autorun"
49
+ Minitest.run
50
+ end
51
+
52
+ def self.parse_coverage_json
53
+ resultset_path = Rails.root.join(Devformance.configuration.coverage_dir || "coverage", ".resultset.json")
54
+ return nil unless File.exist?(resultset_path)
55
+
56
+ JSON.parse(File.read(resultset_path))
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+
61
+ def self.calculate_coverage_pct(resultset, file_path)
62
+ return nil unless resultset
63
+
64
+ rel_path = file_path.sub(Rails.root.to_s + "/", "")
65
+ all_lines = resultset.values.flat_map do |r|
66
+ r.dig("coverage", rel_path)&.compact || []
67
+ end.compact
68
+
69
+ return nil if all_lines.empty?
70
+
71
+ covered = all_lines.count { |v| v.to_i > 0 }
72
+ (covered.to_f / all_lines.size * 100).round(1)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,26 @@
1
+ require_relative "rspec"
2
+ require_relative "minitest"
3
+
4
+ module Devformance
5
+ module TestFramework
6
+ class Detector
7
+ FRAMEWORKS = [ RSpec, Minitest ].freeze
8
+
9
+ def self.detect(root_path)
10
+ FRAMEWORKS.each do |framework_class|
11
+ adapter = framework_class.new(root_path)
12
+ return adapter if adapter.available?
13
+ end
14
+ nil
15
+ end
16
+
17
+ def self.all
18
+ FRAMEWORKS
19
+ end
20
+
21
+ def self.for_name(name)
22
+ FRAMEWORKS.find { |f| f.name == name }
23
+ end
24
+ end
25
+ end
26
+ end