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
@@ -0,0 +1,34 @@
1
+ FactoryBot.define do
2
+ factory :file_result, class: "Devformance::FileResult" do
3
+ run_id { SecureRandom.hex(8) }
4
+ file_key { "posts_controller_spec" }
5
+ file_path { "spec/requests/posts_controller_spec.rb" }
6
+ status { :pending }
7
+ total_tests { 0 }
8
+ passed_tests { 0 }
9
+ failed_tests { 0 }
10
+ slow_query_count { 0 }
11
+ n1_count { 0 }
12
+ coverage { nil }
13
+ duration_ms { nil }
14
+ log_path { nil }
15
+ end
16
+
17
+ trait :passed do
18
+ status { :passed }
19
+ total_tests { 10 }
20
+ passed_tests { 10 }
21
+ failed_tests { 0 }
22
+ coverage { 95.5 }
23
+ duration_ms { 2500 }
24
+ end
25
+
26
+ trait :failed do
27
+ status { :failed }
28
+ total_tests { 10 }
29
+ passed_tests { 7 }
30
+ failed_tests { 3 }
31
+ coverage { 85.0 }
32
+ duration_ms { 3000 }
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ FactoryBot.define do
2
+ factory :slow_query, class: "Devformance::SlowQuery" do
3
+ model_class { "User" }
4
+ line_number { 42 }
5
+ fix_suggestion { "Use includes to eager load associations" }
6
+ run_id { SecureRandom.hex(8) }
7
+ sql { "SELECT * FROM users WHERE active = true" }
8
+ duration_ms { 150.5 }
9
+ file_key { "users_controller_spec" }
10
+ end
11
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::LogWriter do
6
+ let(:tmpdir) { Dir.mktmpdir }
7
+
8
+ after { FileUtils.rm_rf(tmpdir) }
9
+
10
+ describe ".open" do
11
+ subject(:writer) { described_class.open(run_id, file_key) }
12
+
13
+ let(:run_id) { "abc123" }
14
+ let(:file_key) { "users_spec" }
15
+
16
+ before { allow(Rails.root).to receive(:join).with("log", "devformance", "runs").and_return(Pathname.new(tmpdir)) }
17
+
18
+ it "returns a LogWriter instance" do
19
+ expect(writer).to be_a(described_class)
20
+ ensure
21
+ writer.close
22
+ end
23
+
24
+ it "creates the run directory" do
25
+ writer
26
+ expect(File.directory?(File.join(tmpdir, run_id))).to be true
27
+ ensure
28
+ writer.close
29
+ end
30
+
31
+ it "creates a log file at the expected path" do
32
+ writer
33
+ expect(File.exist?(File.join(tmpdir, run_id, "#{file_key}.log"))).to be true
34
+ ensure
35
+ writer.close
36
+ end
37
+ end
38
+
39
+ describe "#path" do
40
+ subject(:writer) { described_class.new(log_path) }
41
+
42
+ let(:log_path) { Pathname.new(tmpdir).join("test.log") }
43
+
44
+ it "returns the path as a string" do
45
+ expect(writer.path).to eq(log_path.to_s)
46
+ ensure
47
+ writer.close
48
+ end
49
+ end
50
+
51
+ describe "#write" do
52
+ subject(:writer) { described_class.new(log_path) }
53
+
54
+ let(:log_path) { Pathname.new(tmpdir).join("output.log") }
55
+
56
+ it "writes the line to the file with a newline" do
57
+ writer.write("hello world")
58
+ writer.close
59
+ expect(File.read(log_path)).to eq("hello world\n")
60
+ end
61
+
62
+ it "flushes immediately so content is readable before close" do
63
+ writer.write("flushed")
64
+ expect(File.read(log_path)).to include("flushed")
65
+ ensure
66
+ writer.close
67
+ end
68
+ end
69
+
70
+ describe "#close" do
71
+ subject(:writer) { described_class.new(log_path) }
72
+
73
+ let(:log_path) { Pathname.new(tmpdir).join("close_test.log") }
74
+
75
+ it "closes the underlying file" do
76
+ io = writer.instance_variable_get(:@file)
77
+ writer.close
78
+ expect(io).to be_closed
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::RunOrchestrator do
6
+ describe ".call" do
7
+ subject(:result) { described_class.call }
8
+
9
+ before do
10
+ allow(described_class).to receive(:new).and_return(instance)
11
+ end
12
+
13
+ let(:instance) { instance_double(described_class, call: { run_id: "abc", file_count: 1, files: [] }) }
14
+
15
+ it "delegates to a new instance" do
16
+ result
17
+ expect(instance).to have_received(:call)
18
+ end
19
+
20
+ it "returns the instance result" do
21
+ expect(result).to eq({ run_id: "abc", file_count: 1, files: [] })
22
+ end
23
+ end
24
+
25
+ describe "#call" do
26
+ subject(:result) { described_class.new.call }
27
+
28
+ let(:file_paths) { ["spec/requests/orders_spec.rb", "spec/requests/users_spec.rb"] }
29
+ let(:run) { instance_double(Devformance::Run, run_id: "deadbeef12345678", total_files: 2) }
30
+
31
+ before do
32
+ allow(Dir).to receive(:glob).and_return(file_paths)
33
+ allow(File).to receive(:read).and_return("")
34
+ allow(Devformance::Run).to receive(:create_for_files).and_return(run)
35
+ allow(Devformance::FileResult).to receive(:create!).and_return(true)
36
+ allow(ActionCable.server).to receive(:broadcast)
37
+ allow(Devformance::FileRunnerJob).to receive(:perform_later)
38
+ end
39
+
40
+ context "when spec files are found" do
41
+ it "returns the run_id" do
42
+ expect(result[:run_id]).to eq("deadbeef12345678")
43
+ end
44
+
45
+ it "returns the file count" do
46
+ expect(result[:file_count]).to eq(2)
47
+ end
48
+
49
+ it "returns file metadata for each file" do
50
+ expect(result[:files].size).to eq(2)
51
+ end
52
+
53
+ it "broadcasts run_started on the run stream" do
54
+ result
55
+ expect(ActionCable.server).to have_received(:broadcast).with(
56
+ "devformance:run:deadbeef12345678",
57
+ hash_including(type: "run_started", run_id: "deadbeef12345678")
58
+ )
59
+ end
60
+
61
+ it "enqueues a FileRunnerJob for each file" do
62
+ result
63
+ expect(Devformance::FileRunnerJob).to have_received(:perform_later).twice
64
+ end
65
+
66
+ it "creates a FileResult record for each file" do
67
+ result
68
+ expect(Devformance::FileResult).to have_received(:create!).twice
69
+ end
70
+ end
71
+
72
+ context "when no spec files are found" do
73
+ before { allow(Dir).to receive(:glob).and_return([]) }
74
+
75
+ it "returns an error key" do
76
+ expect(result).to have_key(:error)
77
+ end
78
+
79
+ it "does not enqueue any jobs" do
80
+ result
81
+ expect(Devformance::FileRunnerJob).not_to have_received(:perform_later)
82
+ end
83
+ end
84
+
85
+ context "when some files are tagged with devformance" do
86
+ let(:tagged_path) { "spec/requests/tagged_spec.rb" }
87
+ let(:untagged_path) { "spec/requests/plain_spec.rb" }
88
+
89
+ before do
90
+ allow(Dir).to receive(:glob).and_return([tagged_path, untagged_path])
91
+ allow(File).to receive(:read).with(tagged_path).and_return("# devformance: true")
92
+ allow(File).to receive(:read).with(untagged_path).and_return("# plain spec")
93
+ allow(Devformance::Run).to receive(:create_for_files).with([tagged_path]).and_return(run)
94
+ end
95
+
96
+ it "runs only the tagged files" do
97
+ result
98
+ expect(Devformance::Run).to have_received(:create_for_files).with([tagged_path])
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::SqlInstrumentor do
6
+ describe ".around_run" do
7
+ it "sets the thread-local collector during the block" do
8
+ collector_during = nil
9
+ described_class.around_run { collector_during = Thread.current[described_class::THREAD_KEY] }
10
+ expect(collector_during).to include(:queries, :start)
11
+ end
12
+
13
+ it "clears the thread-local collector after the block" do
14
+ described_class.around_run { nil }
15
+ expect(Thread.current[described_class::THREAD_KEY]).to be_nil
16
+ end
17
+
18
+ it "clears the collector even when the block raises" do
19
+ expect { described_class.around_run { raise "boom" } }.to raise_error("boom")
20
+ expect(Thread.current[described_class::THREAD_KEY]).to be_nil
21
+ end
22
+
23
+ it "initializes queries as an empty array" do
24
+ queries_at_start = nil
25
+ described_class.around_run { queries_at_start = Thread.current[described_class::THREAD_KEY][:queries] }
26
+ expect(queries_at_start).to eq([])
27
+ end
28
+ end
29
+
30
+ describe ".record" do
31
+ let(:event) do
32
+ instance_double(ActiveSupport::Notifications::Event,
33
+ duration: 42.5,
34
+ payload: { sql: "SELECT * FROM users" })
35
+ end
36
+
37
+ context "when called outside around_run" do
38
+ it "returns nil without recording" do
39
+ expect(described_class.record(event)).to be_nil
40
+ end
41
+ end
42
+
43
+ context "when called inside around_run" do
44
+ it "records the query with rounded ms and sql" do
45
+ recorded = nil
46
+ described_class.around_run { recorded = described_class.record(event) }
47
+ expect(recorded).to include(sql: "SELECT * FROM users", ms: 42.5)
48
+ end
49
+
50
+ it "includes an iso8601 timestamp" do
51
+ recorded = nil
52
+ described_class.around_run { recorded = described_class.record(event) }
53
+ expect(recorded[:at]).to match(/\A\d{4}-\d{2}-\d{2}T/)
54
+ end
55
+ end
56
+
57
+ context "with transaction control statements" do
58
+ %w[BEGIN COMMIT ROLLBACK SAVEPOINT RELEASE].each do |stmt|
59
+ context "when sql is #{stmt}" do
60
+ let(:event) do
61
+ instance_double(ActiveSupport::Notifications::Event,
62
+ duration: 1.0,
63
+ payload: { sql: stmt })
64
+ end
65
+
66
+ it "does not record the #{stmt} statement" do
67
+ result = nil
68
+ described_class.around_run { result = described_class.record(event) }
69
+ expect(result).to be_nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ context "when sql has leading/trailing whitespace" do
76
+ let(:event) do
77
+ instance_double(ActiveSupport::Notifications::Event,
78
+ duration: 10.0,
79
+ payload: { sql: " SELECT id FROM orders " })
80
+ end
81
+
82
+ it "strips whitespace before recording" do
83
+ recorded = nil
84
+ described_class.around_run { recorded = described_class.record(event) }
85
+ expect(recorded[:sql]).to eq("SELECT id FROM orders")
86
+ end
87
+ end
88
+ end
89
+
90
+ describe ".queries" do
91
+ context "when called outside around_run" do
92
+ it "returns an empty array" do
93
+ expect(described_class.queries).to eq([])
94
+ end
95
+ end
96
+
97
+ context "when called inside around_run after recording" do
98
+ let(:event) do
99
+ instance_double(ActiveSupport::Notifications::Event,
100
+ duration: 5.0,
101
+ payload: { sql: "SELECT 1" })
102
+ end
103
+
104
+ it "returns the recorded queries" do
105
+ queries = nil
106
+ described_class.around_run do
107
+ described_class.record(event)
108
+ queries = described_class.queries
109
+ end
110
+ expect(queries.size).to eq(1)
111
+ expect(queries.first[:sql]).to eq("SELECT 1")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::FileResult, type: :model do
6
+ let(:run) do
7
+ Devformance::Run.create!(
8
+ run_id: SecureRandom.hex(8),
9
+ status: :running,
10
+ total_files: 1,
11
+ started_at: Time.current
12
+ )
13
+ end
14
+
15
+ describe ".file_key_for" do
16
+ subject(:key) { described_class.file_key_for(file_path) }
17
+
18
+ context "with a standard spec path" do
19
+ let(:file_path) { "spec/requests/users_spec.rb" }
20
+
21
+ it "returns the basename without .rb extension" do
22
+ expect(key).to eq("users_spec")
23
+ end
24
+ end
25
+
26
+ context "with hyphens and uppercase in filename" do
27
+ let(:file_path) { "spec/requests/my-complex_spec.rb" }
28
+
29
+ it "replaces non-alphanumeric characters with underscores" do
30
+ expect(key).to eq("my_complex_spec")
31
+ end
32
+ end
33
+
34
+ context "with a deeply nested path" do
35
+ let(:file_path) { "/home/user/app/spec/requests/api/v1/orders_spec.rb" }
36
+
37
+ it "uses only the basename" do
38
+ expect(key).to eq("orders_spec")
39
+ end
40
+ end
41
+ end
42
+
43
+ describe "enum status" do
44
+ subject(:result) do
45
+ described_class.create!(
46
+ run_id: run.run_id,
47
+ file_key: "foo_spec",
48
+ file_path: "spec/requests/foo_spec.rb",
49
+ status: :pending
50
+ )
51
+ end
52
+
53
+ it "starts as pending" do
54
+ expect(result).to be_pending
55
+ end
56
+
57
+ it "transitions to running" do
58
+ result.running!
59
+ expect(result).to be_running
60
+ end
61
+
62
+ it "transitions to passed" do
63
+ result.passed!
64
+ expect(result).to be_passed
65
+ end
66
+
67
+ it "transitions to failed" do
68
+ result.failed!
69
+ expect(result).to be_failed
70
+ end
71
+ end
72
+
73
+ describe "associations" do
74
+ subject(:result) do
75
+ described_class.create!(
76
+ run_id: run.run_id,
77
+ file_key: "foo_spec",
78
+ file_path: "spec/requests/foo_spec.rb",
79
+ status: :pending
80
+ )
81
+ end
82
+
83
+ it "belongs to a run via run_id" do
84
+ expect(result.run).to eq(run)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::Run, type: :model do
6
+ describe ".create_for_files" do
7
+ subject(:run) { described_class.create_for_files(file_paths) }
8
+
9
+ let(:file_paths) { ["spec/requests/foo_spec.rb", "spec/requests/bar_spec.rb"] }
10
+
11
+ it "creates a record with a hex run_id" do
12
+ expect(run.run_id).to match(/\A[0-9a-f]{16}\z/)
13
+ end
14
+
15
+ it "sets status to running" do
16
+ expect(run).to be_running
17
+ end
18
+
19
+ it "sets total_files to the number of paths" do
20
+ expect(run.total_files).to eq(2)
21
+ end
22
+
23
+ it "sets started_at to current time" do
24
+ freeze_time = Time.current
25
+ allow(Time).to receive(:current).and_return(freeze_time)
26
+ expect(run.started_at.to_i).to eq(freeze_time.to_i)
27
+ end
28
+
29
+ it "persists to the database" do
30
+ expect { run }.to change(described_class, :count).by(1)
31
+ end
32
+ end
33
+
34
+ describe "enum status" do
35
+ subject(:run) { described_class.create!(run_id: SecureRandom.hex(8), status: :pending, total_files: 1, started_at: Time.current) }
36
+
37
+ it "transitions from pending to running" do
38
+ run.running!
39
+ expect(run).to be_running
40
+ end
41
+
42
+ it "transitions to completed" do
43
+ run.completed!
44
+ expect(run).to be_completed
45
+ end
46
+
47
+ it "transitions to failed" do
48
+ run.failed!
49
+ expect(run).to be_failed
50
+ end
51
+ end
52
+
53
+ describe "associations" do
54
+ subject(:run) { described_class.create!(run_id: SecureRandom.hex(8), status: :running, total_files: 1, started_at: Time.current) }
55
+
56
+ it "has many file_results" do
57
+ result = Devformance::FileResult.create!(
58
+ run_id: run.run_id,
59
+ file_key: "foo_spec",
60
+ file_path: "spec/requests/foo_spec.rb",
61
+ status: :pending
62
+ )
63
+ expect(run.file_results).to include(result)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe QueryLog, type: :model do
6
+ describe "#initialize" do
7
+ subject(:instance) { described_class.new(query: "SELECT 1") }
8
+
9
+ it "stores the query attribute" do
10
+ expect(instance.query).to eq("SELECT 1")
11
+ end
12
+ end
13
+
14
+ describe "validations" do
15
+ subject(:log) { described_class.new(query: "SELECT * FROM users", duration: 150, user_id: 1) }
16
+
17
+ it "is valid with basic attributes" do
18
+ expect(log).to be_valid
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ ENV['RAILS_ENV'] ||= 'test'
2
+ require_relative '../config/environment'
3
+ require 'rspec/rails'
4
+ require 'factory_bot'
5
+ require 'simplecov'
6
+ SimpleCov.start 'rails' do
7
+ enable_coverage :branch
8
+ add_filter '/spec/'
9
+ end
10
+
11
+ RSpec.configure do |config|
12
+ config.use_transactional_fixtures = true
13
+ config.infer_spec_type_from_file_location!
14
+ config.include FactoryBot::Syntax::Methods
15
+ end
16
+
17
+ Dir[Rails.root.join("spec/fixtures/devformance/**/*.rb")].each { |f| require f }
18
+
19
+ require_relative '../spec/support/devformance_formatter'
20
+ require_relative '../spec/support/devformance_metrics'
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Devformance::MetricsController, type: :request do
6
+ describe "GET /devformance" do
7
+ subject { get "/devformance" }
8
+
9
+ it "returns 200" do
10
+ subject
11
+ expect(response).to have_http_status(200)
12
+ end
13
+ end
14
+
15
+ describe "POST /devformance/run_tests" do
16
+ subject { post "/devformance/run_tests", headers: { "Content-Type" => "application/json" } }
17
+
18
+ context "when the orchestrator succeeds" do
19
+ let(:orchestrator_result) do
20
+ { run_id: "abc123", file_count: 2, files: [{ file_key: "a_spec", display_name: "a_spec.rb" }] }
21
+ end
22
+
23
+ before do
24
+ allow(Devformance::RunOrchestrator).to receive(:call).and_return(orchestrator_result)
25
+ end
26
+
27
+ it "returns 202" do
28
+ subject
29
+ expect(response).to have_http_status(202)
30
+ end
31
+
32
+ it "includes the run_id in the response" do
33
+ subject
34
+ expect(JSON.parse(response.body)["run_id"]).to eq("abc123")
35
+ end
36
+
37
+ it "includes the files array" do
38
+ subject
39
+ expect(JSON.parse(response.body)["files"]).to be_an(Array)
40
+ end
41
+ end
42
+
43
+ context "when the orchestrator returns an error" do
44
+ before do
45
+ allow(Devformance::RunOrchestrator).to receive(:call).and_return({ error: "No request specs found" })
46
+ end
47
+
48
+ it "returns 422" do
49
+ subject
50
+ expect(response).to have_http_status(422)
51
+ end
52
+
53
+ it "includes the error message" do
54
+ subject
55
+ expect(JSON.parse(response.body)["error"]).to eq("No request specs found")
56
+ end
57
+ end
58
+ end
59
+
60
+ describe "GET /devformance/runs/:run_id/status" do
61
+ let(:run_id) { SecureRandom.hex(8) }
62
+
63
+ context "when the run exists" do
64
+ let!(:run) do
65
+ Devformance::Run.create!(
66
+ run_id: run_id,
67
+ status: :running,
68
+ total_files: 1,
69
+ started_at: Time.current
70
+ )
71
+ end
72
+
73
+ it "returns 200" do
74
+ get "/devformance/runs/#{run_id}/status"
75
+ expect(response).to have_http_status(200)
76
+ end
77
+
78
+ it "includes the run status" do
79
+ get "/devformance/runs/#{run_id}/status"
80
+ expect(JSON.parse(response.body)["status"]).to eq("running")
81
+ end
82
+ end
83
+
84
+ context "when the run does not exist" do
85
+ it "returns 404" do
86
+ get "/devformance/runs/nonexistent/status"
87
+ expect(response).to have_http_status(404)
88
+ end
89
+ end
90
+ end
91
+
92
+ describe "GET /devformance/runs/:run_id/logs/:file_key/download" do
93
+ let(:run_id) { SecureRandom.hex(8) }
94
+ let(:file_key) { "orders_spec" }
95
+ let(:run) do
96
+ Devformance::Run.create!(
97
+ run_id: run_id,
98
+ status: :completed,
99
+ total_files: 1,
100
+ started_at: Time.current
101
+ )
102
+ end
103
+
104
+ context "when the file result exists with a readable log" do
105
+ let(:tmpfile) { Tempfile.new("orders_spec.log") }
106
+
107
+ let!(:result) do
108
+ Devformance::FileResult.create!(
109
+ run_id: run.run_id,
110
+ file_key: file_key,
111
+ file_path: "spec/requests/orders_spec.rb",
112
+ status: :passed,
113
+ log_path: tmpfile.path
114
+ )
115
+ end
116
+
117
+ after { tmpfile.unlink }
118
+
119
+ it "returns 200 and sends the file" do
120
+ get "/devformance/runs/#{run_id}/logs/#{file_key}/download"
121
+ expect(response).to have_http_status(200)
122
+ end
123
+ end
124
+
125
+ context "when the file result does not exist" do
126
+ it "returns 404" do
127
+ get "/devformance/runs/#{run_id}/logs/missing/download"
128
+ expect(response).to have_http_status(404)
129
+ end
130
+ end
131
+
132
+ context "when the file result exists but log file is missing from disk" do
133
+ let!(:result) do
134
+ Devformance::FileResult.create!(
135
+ run_id: run.run_id,
136
+ file_key: file_key,
137
+ file_path: "spec/requests/orders_spec.rb",
138
+ status: :passed,
139
+ log_path: "/tmp/devformance_nonexistent_#{SecureRandom.hex}.log"
140
+ )
141
+ end
142
+
143
+ it "returns 404" do
144
+ get "/devformance/runs/#{run_id}/logs/#{file_key}/download"
145
+ expect(response).to have_http_status(404)
146
+ end
147
+ end
148
+ end
149
+ end