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,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
|