haveapi 0.30.1 → 0.30.2
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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +730 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +579 -0
- data/doc/client-examples.md +110 -3
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/version.rb +1 -1
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +342 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +279 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +431 -0
- data/spec/performance/runner_spec.rb +476 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- metadata +23 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../test_support/performance_endpoint_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::PerformanceFixtures::Endpoints do
|
|
5
|
+
let(:scenarios) { [] }
|
|
6
|
+
|
|
7
|
+
def build(id = 'api.basic_get', **dimensions)
|
|
8
|
+
scenario = described_class.build(id:, dimensions:)
|
|
9
|
+
scenarios << scenario
|
|
10
|
+
scenario
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def headers(user = 'member')
|
|
14
|
+
{ 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json',
|
|
15
|
+
'HTTP_AUTHORIZATION' => "Basic #{["#{user}:#{described_class::PASSWORD}"].pack('m0')}",
|
|
16
|
+
'CONTENT_TYPE' => 'application/json' }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json_request(request, method, path, body = nil, user: 'member')
|
|
20
|
+
response = request.request(method, "/performance/v1/#{path}",
|
|
21
|
+
headers(user).merge(input: body.nil? ? '' : JSON.generate(body)))
|
|
22
|
+
[response, JSON.parse(response.body)]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
after do
|
|
26
|
+
scenarios.each(&:close)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'retains the seven quick cases and adds only the accepted weekly shapes' do
|
|
30
|
+
quick = described_class.cases
|
|
31
|
+
expect(quick.map { |item| item[:id] }).to eq(described_class::CASE_IDS)
|
|
32
|
+
expect(quick.last).to eq(id: 'api.list', dimensions: { rows: 32 })
|
|
33
|
+
expect(quick[0...-1].map { |item| item[:dimensions] }).to all(eq({}))
|
|
34
|
+
weekly = described_class.cases(profile: 'weekly')
|
|
35
|
+
expect(weekly.first(quick.length)).to eq(quick)
|
|
36
|
+
additions = [
|
|
37
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'member' } },
|
|
38
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'admin' } },
|
|
39
|
+
{ id: 'api.basic_get', dimensions: { user: 'admin' } }
|
|
40
|
+
]
|
|
41
|
+
expect(weekly.drop(quick.length)).to eq(additions)
|
|
42
|
+
expect(weekly.uniq).to eq(weekly)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'executes and validates every real quick-case response and counter record' do
|
|
46
|
+
described_class.cases.each do |item|
|
|
47
|
+
scenario = build(item[:id], **item[:dimensions])
|
|
48
|
+
scenario.reset!(iterations: 3)
|
|
49
|
+
responses = Array.new(3) { scenario.call }
|
|
50
|
+
responses.each_with_index do |response, index|
|
|
51
|
+
identity = scenario.validate!(response, index:)
|
|
52
|
+
expect(identity).to include(http_status: 200, body_bytes: response.body.bytesize,
|
|
53
|
+
body_sha256: Digest::SHA256.hexdigest(response.body))
|
|
54
|
+
expect(identity[:envelope_status]).to eq(item[:id] != 'api.invalid_post')
|
|
55
|
+
expect(identity[:canonical_body]).to eq(JSON.parse(response.body))
|
|
56
|
+
expect(identity[:canonical_sha256]).to eq(HaveAPI::Performance::Identity.digest(JSON.parse(response.body)))
|
|
57
|
+
end
|
|
58
|
+
executions = item[:id] == 'api.invalid_post' ? 0 : 1
|
|
59
|
+
expect(scenario.counters).to eq(operations: Array.new(3) { { queries: 0, authorizations: 1, executions: } })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'validates the weekly large-list and administrator shapes' do
|
|
64
|
+
described_class.cases(profile: 'weekly').drop(described_class::CASE_IDS.length).each do |item|
|
|
65
|
+
scenario = build(item[:id], **item[:dimensions])
|
|
66
|
+
scenario.reset!(iterations: 1)
|
|
67
|
+
identity = scenario.validate!(scenario.call, index: 0)
|
|
68
|
+
output = identity.fetch(:canonical_body).fetch('response').fetch('probe')
|
|
69
|
+
records = item[:id] == 'api.list' ? output : [output]
|
|
70
|
+
expect(records.length).to eq(item[:dimensions].fetch(:rows, 1))
|
|
71
|
+
records.each do |record|
|
|
72
|
+
expect(record.has_key?('secret')).to eq(item[:dimensions][:user] == 'admin')
|
|
73
|
+
end
|
|
74
|
+
expect(scenario.counters).to eq(operations: [{ queries: 0, authorizations: 1, executions: 1 }])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'rejects a corrupted early response even when the final response is valid' do
|
|
79
|
+
scenario = build
|
|
80
|
+
scenario.reset!(iterations: 2)
|
|
81
|
+
first = scenario.call
|
|
82
|
+
last = scenario.call
|
|
83
|
+
body = JSON.parse(first.body)
|
|
84
|
+
body['response']['probe']['count'] = '0'
|
|
85
|
+
broken = Rack::MockResponse.new(200, first.headers, JSON.generate(body))
|
|
86
|
+
|
|
87
|
+
expect { scenario.validate!(last, index: 1) }.not_to raise_error
|
|
88
|
+
expect { scenario.validate!(broken, index: 0) }.to raise_error(/Unexpected output/)
|
|
89
|
+
expect { scenario.validate!(Rack::MockResponse.new(403, {}, '{}'), index: 0) }
|
|
90
|
+
.to raise_error(/HTTP status/)
|
|
91
|
+
duplicate = Rack::MockResponse.new(200, first.headers, first.body.sub('"status":true', '"status":false,"status":true'))
|
|
92
|
+
expect { scenario.validate!(duplicate, index: 0) }.to raise_error(HaveAPI::Performance::ContractError, /duplicate/i)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'preserves typed zero, false, empty, null, nested Custom data and omission' do
|
|
96
|
+
scenario = build('api.typed_post')
|
|
97
|
+
scenario.reset!(iterations: 1)
|
|
98
|
+
response = scenario.call
|
|
99
|
+
scenario.validate!(response, index: 0)
|
|
100
|
+
output = JSON.parse(response.body).fetch('response').fetch('probe')
|
|
101
|
+
|
|
102
|
+
expect(output).to include('count' => 0, 'ratio' => 1.25, 'enabled' => false, 'label' => '', 'nullable' => nil,
|
|
103
|
+
'at' => '2020-01-01T00:00:00+00:00', 'payload' => { 'nested' => [0, false, '', nil] })
|
|
104
|
+
expect(output).not_to have_key('omitted')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'uses distinct Basic and token providers with real credential failures' do
|
|
108
|
+
basic = build('api.basic_get')
|
|
109
|
+
token = build('api.token_get')
|
|
110
|
+
[basic, token].each { |scenario| scenario.reset!(iterations: 1) }
|
|
111
|
+
expect(basic.validate!(basic.call, index: 0)).to eq(token.validate!(token.call, index: 0))
|
|
112
|
+
request = Rack::MockRequest.new(basic.app)
|
|
113
|
+
missing = request.get('/performance/v1/probes/secure', 'HTTP_HOST' => 'example.test')
|
|
114
|
+
bad_basic = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => 'Basic invalid'))
|
|
115
|
+
bad_token = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => '',
|
|
116
|
+
'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'invalid'))
|
|
117
|
+
expect([missing.status, bad_basic.status, bad_token.status]).to eq([401, 401, 401])
|
|
118
|
+
[missing, bad_basic, bad_token].each { |response| expect(JSON.parse(response.body)['status']).to be(false) }
|
|
119
|
+
denied, body = json_request(request, 'GET', 'probes/admin')
|
|
120
|
+
expect(denied.status).to eq(403)
|
|
121
|
+
expect(body.fetch('status')).to be(false)
|
|
122
|
+
allowed, body = json_request(request, 'GET', 'probes/admin', user: 'admin')
|
|
123
|
+
expect(allowed.status).to eq(200)
|
|
124
|
+
expect(body.dig('response', 'probe', 'secret')).to eq('ADMIN_ONLY_VALUE')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'executes real CRUD, isolates stores and resets writes outside measured requests' do
|
|
128
|
+
scenario = build('api.list', rows: 1)
|
|
129
|
+
other = build('api.list', rows: 1)
|
|
130
|
+
scenario.reset!(iterations: 1)
|
|
131
|
+
other.reset!(iterations: 1)
|
|
132
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
133
|
+
created, body = json_request(request, 'POST', 'items', { probe: { label: 'created' } })
|
|
134
|
+
expect(created.status).to eq(200)
|
|
135
|
+
expect(body.fetch('status')).to be(true)
|
|
136
|
+
expect(body.dig('response', 'probe')).to include('id' => 2, 'label' => 'created')
|
|
137
|
+
_, body = json_request(request, 'GET', 'items/2')
|
|
138
|
+
expect(body.dig('response', 'probe', 'label')).to eq('created')
|
|
139
|
+
_, body = json_request(request, 'PUT', 'items/2', { probe: { label: 'updated' } })
|
|
140
|
+
expect(body.dig('response', 'probe', 'label')).to eq('updated')
|
|
141
|
+
_, body = json_request(request, 'DELETE', 'items/2')
|
|
142
|
+
expect(body.dig('response', 'probe', 'label')).to eq('updated')
|
|
143
|
+
missing, body = json_request(request, 'GET', 'items/2')
|
|
144
|
+
expect(missing.status).to eq(404)
|
|
145
|
+
expect(body.fetch('status')).to be(false)
|
|
146
|
+
json_request(request, 'POST', 'items', { probe: { label: 'discarded by reset' } })
|
|
147
|
+
expect(other.validate!(other.call, index: 0)[:envelope_status]).to be(true)
|
|
148
|
+
scenario.reset!(iterations: 1)
|
|
149
|
+
expect(scenario.validate!(scenario.call, index: 0)[:envelope_status]).to be(true)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'does not mutate timed counter records through direct app requests' do
|
|
153
|
+
scenario = build
|
|
154
|
+
scenario.reset!(iterations: 1)
|
|
155
|
+
response = scenario.call
|
|
156
|
+
before = Marshal.dump(scenario.counters)
|
|
157
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
158
|
+
json_request(request, 'GET', 'probes/secure')
|
|
159
|
+
json_request(request, 'POST', 'items', { probe: { label: 'untimed' } })
|
|
160
|
+
|
|
161
|
+
expect(Marshal.dump(scenario.counters)).to eq(before)
|
|
162
|
+
expect { scenario.validate!(response, index: 0) }.not_to raise_error
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'rejects malformed bodies, missing routes and invalid typed input through real middleware' do
|
|
166
|
+
scenario = build
|
|
167
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
168
|
+
['{', '[]', 'null'].each do |input|
|
|
169
|
+
response = request.post('/performance/v1/probes/typed', headers.merge(input:))
|
|
170
|
+
expect(response.status).to eq(400)
|
|
171
|
+
expect(JSON.parse(response.body)['status']).to be(false)
|
|
172
|
+
end
|
|
173
|
+
response = request.get('/performance/v1/not-a-route', headers)
|
|
174
|
+
expect(response.status).to eq(404)
|
|
175
|
+
expect(JSON.parse(response.body)['status']).to be(false)
|
|
176
|
+
response = request.post('/performance/v1/probes/typed', headers.merge(input: '{"probe":{"count":"wrong"}}'))
|
|
177
|
+
expect(response.status).to eq(200)
|
|
178
|
+
expect(JSON.parse(response.body).fetch('errors')).to have_key('count')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'alternates privileged and restricted users without exposing hidden fields' do
|
|
182
|
+
scenario = build
|
|
183
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
184
|
+
%w[admin member admin].each do |user|
|
|
185
|
+
_, body = json_request(request, 'GET', 'probes/secure', user:)
|
|
186
|
+
output = body.fetch('response').fetch('probe')
|
|
187
|
+
if user == 'admin'
|
|
188
|
+
expect(output.fetch('secret')).to eq('ADMIN_ONLY_VALUE')
|
|
189
|
+
else
|
|
190
|
+
expect(output).not_to have_key('secret')
|
|
191
|
+
expect(JSON.generate(body)).not_to include('ADMIN_ONLY_VALUE')
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'requires reset and every operation record, and rejects overflow and damaged counters' do
|
|
197
|
+
scenario = build
|
|
198
|
+
expect { scenario.call }.to raise_error(/reset!/)
|
|
199
|
+
expect { scenario.counters }.to raise_error(/Missing operation/)
|
|
200
|
+
scenario.reset!(iterations: 2)
|
|
201
|
+
first = scenario.call
|
|
202
|
+
expect { scenario.counters }.to raise_error(/Missing operation/)
|
|
203
|
+
scenario.call
|
|
204
|
+
expect { scenario.call }.to raise_error(/More calls/)
|
|
205
|
+
expect { scenario.validate!(first, index: -1) }.to raise_error(ArgumentError)
|
|
206
|
+
expect { scenario.validate!(first, index: 2) }.to raise_error(/not called/)
|
|
207
|
+
scenario.counters[:operations].first[:queries] = 1
|
|
208
|
+
expect { scenario.validate!(first, index: 0) }.to raise_error(/counts/)
|
|
209
|
+
expect { scenario.counters }.to raise_error(/counts/)
|
|
210
|
+
scenario.reset!(iterations: 1)
|
|
211
|
+
scenario.validate!(scenario.call, index: 0)
|
|
212
|
+
scenario.close
|
|
213
|
+
expect { scenario.call }.to raise_error(/closed/)
|
|
214
|
+
expect { scenario.reset!(iterations: 1) }.to raise_error(/closed/)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'fails closed on unknown cases, profiles, dimensions, users and invalid batch sizes' do
|
|
218
|
+
expect { described_class.cases(profile: 'other') }.to raise_error(ArgumentError)
|
|
219
|
+
expect { build('api.unknown') }.to raise_error(ArgumentError)
|
|
220
|
+
expect { build('api.list', rows: 0) }.to raise_error(ArgumentError)
|
|
221
|
+
expect { build('api.list', rows: '32') }.to raise_error(ArgumentError)
|
|
222
|
+
expect { build('api.list', user: 'unknown') }.to raise_error(ArgumentError)
|
|
223
|
+
expect { build('api.list', accidental: true) }.to raise_error(ArgumentError)
|
|
224
|
+
expect { build.reset!(iterations: 0) }.to raise_error(ArgumentError)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../test_support/performance_endpoint_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::PerformanceFixtures::Endpoints, '.build' do
|
|
5
|
+
def run_list(rows)
|
|
6
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: })
|
|
7
|
+
scenario.reset!(iterations: 2)
|
|
8
|
+
responses = Array.new(2) { scenario.call }
|
|
9
|
+
identities = responses.each_with_index.map { |response, index| scenario.validate!(response, index:) }
|
|
10
|
+
[identities, scenario.counters]
|
|
11
|
+
ensure
|
|
12
|
+
scenario&.close
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def allocated_per_request(rows)
|
|
16
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: })
|
|
17
|
+
2.times do
|
|
18
|
+
scenario.reset!(iterations: 10)
|
|
19
|
+
10.times { scenario.call }
|
|
20
|
+
end
|
|
21
|
+
Array.new(3) do
|
|
22
|
+
scenario.reset!(iterations: 10)
|
|
23
|
+
responses = Array.new(10)
|
|
24
|
+
GC.start
|
|
25
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
26
|
+
index = 0
|
|
27
|
+
while index < responses.length
|
|
28
|
+
responses[index] = scenario.call
|
|
29
|
+
index += 1
|
|
30
|
+
end
|
|
31
|
+
delta = GC.stat(:total_allocated_objects) - allocated
|
|
32
|
+
responses.each_with_index { |response, operation| scenario.validate!(response, index: operation) }
|
|
33
|
+
scenario.counters
|
|
34
|
+
delta / 10.0
|
|
35
|
+
end.min
|
|
36
|
+
ensure
|
|
37
|
+
scenario&.close
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'serializes complete ordered lists at sizes 1, 32 and 128 with fixed action work' do
|
|
41
|
+
bytes = [1, 32, 128].map do |rows|
|
|
42
|
+
identities, counters = run_list(rows)
|
|
43
|
+
expect(identities.first).to eq(identities.last)
|
|
44
|
+
output = identities.first[:canonical_body].fetch('response').fetch('probe')
|
|
45
|
+
expect(output.length).to eq(rows)
|
|
46
|
+
expect(output.map { |row| row.fetch('id') }).to eq((1..rows).to_a)
|
|
47
|
+
expect(output.last.fetch('label')).to eq("item-#{rows}")
|
|
48
|
+
output.each { |row| expect(row).not_to have_key('secret') }
|
|
49
|
+
expect(counters[:operations]).to eq(Array.new(2) { { queries: 0, authorizations: 1, executions: 1 } })
|
|
50
|
+
identities.first[:body_bytes]
|
|
51
|
+
end
|
|
52
|
+
expect(bytes.each_cons(2)).to all(satisfy { |small, large| small < large })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'keeps output and counters independent across reset batch sizes' do
|
|
56
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: 32 })
|
|
57
|
+
scenario.reset!(iterations: 1)
|
|
58
|
+
first = scenario.validate!(scenario.call, index: 0)
|
|
59
|
+
records = scenario.counters[:operations]
|
|
60
|
+
scenario.reset!(iterations: 3)
|
|
61
|
+
responses = Array.new(3) { scenario.call }
|
|
62
|
+
responses.each_with_index do |response, index|
|
|
63
|
+
expect(scenario.validate!(response, index:)).to eq(first)
|
|
64
|
+
end
|
|
65
|
+
expect(scenario.counters[:operations].length).to eq(3)
|
|
66
|
+
expect(scenario.counters[:operations].first).not_to equal(records.first)
|
|
67
|
+
expect(scenario.counters[:operations].map(&:object_id).uniq.length).to eq(3)
|
|
68
|
+
ensure
|
|
69
|
+
scenario&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'bounds allocation growth by serialized rows rather than their square' do
|
|
73
|
+
small, medium, large = [1, 32, 128].map { |rows| allocated_per_request(rows) }
|
|
74
|
+
|
|
75
|
+
# Reference Ruby 3.4.11: 640/795/1275 objects per request. Output work
|
|
76
|
+
# grows with rows; the 96-row increment has a 4x allowance relative to
|
|
77
|
+
# the 31-row increment, plus 32 objects of fixed VM bookkeeping slack.
|
|
78
|
+
expect(medium).to be >= small
|
|
79
|
+
expect(large).to be > medium
|
|
80
|
+
expect(large - medium).to be <= (4 * (medium - small)) + 32
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative '../../../../utils/performance_ci'
|
|
6
|
+
|
|
7
|
+
RSpec.describe HaveAPI::PerformanceCI do
|
|
8
|
+
let(:candidate) { sha('c') }
|
|
9
|
+
let(:default) { sha('d') }
|
|
10
|
+
let(:base) { sha('b') }
|
|
11
|
+
let(:event) { { 'repository' => { 'default_branch' => 'master' } } }
|
|
12
|
+
let(:repository) { instance_double(described_class::Repository) }
|
|
13
|
+
|
|
14
|
+
def sha(character)
|
|
15
|
+
character * 40
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolver
|
|
19
|
+
described_class::Resolver.new(repository)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
before do
|
|
23
|
+
allow(repository).to receive(:default_sha).with('master').and_return(default)
|
|
24
|
+
allow(repository).to receive(:commit!) do |value|
|
|
25
|
+
unless value.is_a?(String) && described_class::SHA.match?(value) && value != '0' * 40
|
|
26
|
+
raise described_class::Error, 'invalid commit'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
allow(repository).to receive_messages(ancestor?: true, paths: ['servers/ruby/lib/haveapi/server.rb'], release_lineage: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve(name, data, **options)
|
|
35
|
+
resolver.resolve(event_name: name, event: data, event_sha: candidate, **options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'selects the PR event head instead of its synthetic merge commit' do
|
|
39
|
+
event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
|
|
40
|
+
allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
|
|
41
|
+
|
|
42
|
+
result = resolver.resolve(event_name: 'pull_request', event:, event_sha: sha('e'))
|
|
43
|
+
expect(result).to include(candidate_sha: candidate, baseline_sha: base, profile: 'quick', relevant: true)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'compares an entire default-branch push batch against event before' do
|
|
47
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
|
|
48
|
+
allow(repository).to receive(:merge_base)
|
|
49
|
+
|
|
50
|
+
expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate)
|
|
51
|
+
expect(repository).not_to have_received(:merge_base)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[false, true].each do |new_branch|
|
|
55
|
+
it "uses the whole feature merge-base for a #{new_branch ? 'new' : 'subsequent'} push" do
|
|
56
|
+
event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
|
|
57
|
+
'before' => new_branch ? '0' * 40 : sha('a'), 'created' => new_branch)
|
|
58
|
+
allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
|
|
59
|
+
|
|
60
|
+
expect(resolve('push', event, default_sha: default)).to include(baseline_sha: base, captured_default_sha: default)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'rejects initial default-branch pushes without an available predecessor' do
|
|
65
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
|
|
66
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context 'with the verified maintenance lineage' do
|
|
70
|
+
def release
|
|
71
|
+
sha('e')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def lineage
|
|
75
|
+
{ 'candidate_sha' => candidate, 'mapped_sha' => HaveAPI::Performance::Release::MAPPED }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
before do
|
|
79
|
+
allow(repository).to receive(:release_lineage).with(candidate).and_return(lineage)
|
|
80
|
+
allow(repository).to receive(:default_sha).with('haveapi-0.30').and_return(release)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'compares the complete maintenance push batch using event before' do
|
|
84
|
+
event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
|
|
85
|
+
expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
|
|
86
|
+
release_lineage: lineage, reason: 'maintenance event before/after batch')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[false, true].each do |created|
|
|
90
|
+
it "captures the release head for a #{created ? 'new' : 'subsequent'} release feature" do
|
|
91
|
+
event.merge!('ref' => 'refs/heads/feature', 'before' => created ? '0' * 40 : sha('a'), 'after' => candidate)
|
|
92
|
+
allow(repository).to receive(:merge_base).with(candidate, release).and_return(base)
|
|
93
|
+
expect(resolve('push', event)).to include(baseline_sha: base, captured_release_sha: release,
|
|
94
|
+
captured_default_sha: default, release_lineage: lineage)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'fails if the captured release reference is unavailable' do
|
|
99
|
+
event.merge!('ref' => 'refs/heads/feature', 'after' => candidate)
|
|
100
|
+
allow(repository).to receive(:default_sha).with('haveapi-0.30').and_raise(described_class::Error, 'missing release ref')
|
|
101
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error, /missing release ref/)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'accepts only the original weekly reference through the proven mapping' do
|
|
105
|
+
event['inputs'] = { 'profile' => 'weekly', 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE }
|
|
106
|
+
allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
|
|
107
|
+
expect(resolve('workflow_dispatch', event)).to include(baseline_sha: described_class::REFERENCE, release_lineage: lineage)
|
|
108
|
+
event['inputs']['profile'] = 'quick'
|
|
109
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
110
|
+
event['inputs']['profile'] = 'weekly'
|
|
111
|
+
allow(repository).to receive(:release_lineage).with(candidate).and_return(nil)
|
|
112
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'does not accept an unrelated explicit quick baseline' do
|
|
116
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
117
|
+
allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
|
|
118
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'rejects unproven maintenance branch history' do
|
|
123
|
+
event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
|
|
124
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'rejects deleted pushes and tag events' do
|
|
128
|
+
expect { resolve('push', event.merge('deleted' => true)) }.to raise_error(described_class::Error, /Deleted/)
|
|
129
|
+
expect { resolve('push', event.merge('ref' => 'refs/tags/v1')) }.to raise_error(described_class::Error, /branch/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'requires a real ancestor and never substitutes a same-head baseline' do
|
|
133
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
134
|
+
allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
|
|
135
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
136
|
+
event['inputs']['baseline_sha'] = candidate
|
|
137
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /differ/)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'uses the exact scheduled event SHA and preserves fixed reference ancestry' do
|
|
141
|
+
expect(resolve('schedule', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
|
|
142
|
+
expect(repository).to have_received(:ancestor?).with(described_class::REFERENCE, candidate)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'defaults manual runs to captured default and fixed reference' do
|
|
146
|
+
expect(resolve('workflow_dispatch', event)).to include(candidate_sha: default, baseline_sha: described_class::REFERENCE)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'accepts complete explicit manual SHA pairs and weekly profile' do
|
|
150
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE, 'profile' => 'weekly' }
|
|
151
|
+
expect(resolve('workflow_dispatch', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
|
|
152
|
+
event['inputs']['baseline_sha'] = base
|
|
153
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /fixed/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'rejects partial manual pairs, invalid profiles and missing event data' do
|
|
157
|
+
event['inputs'] = { 'candidate_sha' => candidate }
|
|
158
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error)
|
|
159
|
+
event['inputs'] = { 'profile' => 'unbounded' }
|
|
160
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /profile/)
|
|
161
|
+
expect { resolve('pull_request', {}) }.to raise_error(described_class::Error, /Incomplete/)
|
|
162
|
+
expect { resolve('pull_request_target', event.merge('inputs' => {})) }.to raise_error(described_class::Error, /Unsupported/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'does not hide unavailable commits behind a no-path success' do
|
|
166
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
167
|
+
allow(repository).to receive(:commit!).with(base).and_raise(described_class::Error, 'unavailable')
|
|
168
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, 'unavailable')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'skips only irrelevant valid push/PR changes and still forces scheduled/manual work' do
|
|
172
|
+
allow(repository).to receive(:paths).and_return(['clients/php/README.md'])
|
|
173
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
|
|
174
|
+
expect(resolve('push', event)).to include(relevant: false)
|
|
175
|
+
expect(resolve('schedule', event)).to include(relevant: true)
|
|
176
|
+
expect(resolve('workflow_dispatch', event)).to include(relevant: true)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'detects configuration, dependencies, harness and workflow changes' do
|
|
180
|
+
%w[flake.lock Gemfile Makefile .ruby-version servers/ruby/benchmarks/endpoints.rb
|
|
181
|
+
servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
|
|
182
|
+
doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
|
|
183
|
+
expect(described_class::RELEVANT).to match(path)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'only accepts successful job completion in the aggregate gate' do
|
|
188
|
+
expect(described_class.aggregate!('success', 'compared')).to be(true)
|
|
189
|
+
expect(described_class.aggregate!('success', 'no-work')).to be(true)
|
|
190
|
+
%w[failure cancelled skipped unknown].each do |result|
|
|
191
|
+
expect { described_class.aggregate!(result, 'compared') }.to raise_error(described_class::Error)
|
|
192
|
+
end
|
|
193
|
+
[nil, '', 'unknown'].each do |outcome|
|
|
194
|
+
expect { described_class.aggregate!('success', outcome) }.to raise_error(described_class::Error)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
|
|
199
|
+
path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
|
|
200
|
+
script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
|
|
201
|
+
[['success', 'compared', true], ['success', 'no-work', true], ['success', '', false],
|
|
202
|
+
['failure', 'compared', false], ['cancelled', 'compared', false], ['skipped', 'no-work', false]].each do |result, outcome, accepted|
|
|
203
|
+
_output, _error, status = Open3.capture3({ 'PERFORMANCE_RESULT' => result, 'PERFORMANCE_OUTCOME' => outcome },
|
|
204
|
+
'bash', '-e', '-c', script)
|
|
205
|
+
expect(status.success?).to eq(accepted), "#{result}/#{outcome}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
[0, 1, 3].each do |code|
|
|
210
|
+
it "preserves actual runner exit #{code} with a matching report" do
|
|
211
|
+
Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
|
|
212
|
+
checkout = File.join(directory, 'checkout')
|
|
213
|
+
FileUtils.mkdir_p(File.join(checkout, 'servers/ruby/benchmarks'))
|
|
214
|
+
script = <<~RUBY
|
|
215
|
+
require 'json'
|
|
216
|
+
require 'fileutils'
|
|
217
|
+
output = ARGV.fetch(ARGV.index('--output') + 1)
|
|
218
|
+
FileUtils.mkdir_p(output)
|
|
219
|
+
File.write(File.join(output, 'report.json'), JSON.generate(
|
|
220
|
+
schema_version: #{HaveAPI::Performance::SAMPLE_SCHEMA}, status: '#{{ 0 => 'pass', 1 => 'regression', 2 => 'inconclusive', 3 => 'error' }.fetch(code)}',
|
|
221
|
+
baseline_sha: '#{base}', candidate_sha: '#{candidate}'
|
|
222
|
+
))
|
|
223
|
+
exit #{code}
|
|
224
|
+
RUBY
|
|
225
|
+
File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
|
|
226
|
+
allow(repository).to receive(:root).and_return(checkout)
|
|
227
|
+
allow(repository).to receive(:git).with('rev-parse', 'HEAD').and_return(candidate)
|
|
228
|
+
allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
|
|
229
|
+
selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
|
|
230
|
+
'baseline_sha' => base, 'candidate_sha' => candidate }
|
|
231
|
+
campaign = described_class::Campaign.new(repository:, selection:,
|
|
232
|
+
evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
|
|
233
|
+
allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
|
|
234
|
+
allow(campaign).to receive(:export).with(:candidate, candidate).and_return(directory)
|
|
235
|
+
allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
|
|
236
|
+
expect(campaign.run).to eq(code)
|
|
237
|
+
expect(File.read(File.join(directory, 'evidence/runner.exit')).strip).to eq(code.to_s)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'returns error exit 3 from the actual resolver CLI on incomplete input' do
|
|
243
|
+
Dir.mktmpdir('haveapi-event-spec-') do |directory|
|
|
244
|
+
file = File.join(directory, 'event.json')
|
|
245
|
+
File.write(file, '{}')
|
|
246
|
+
script = File.expand_path('../../../../utils/performance_ci.rb', __dir__)
|
|
247
|
+
_output, error, status = Open3.capture3(RbConfig.ruby, script, 'resolve', '--event', file,
|
|
248
|
+
'--event-name', 'pull_request', '--event-sha', candidate,
|
|
249
|
+
'--repository', File.expand_path('../../../..', __dir__),
|
|
250
|
+
'--output', File.join(directory, 'selection.json'))
|
|
251
|
+
expect(status.exitstatus).to eq(3)
|
|
252
|
+
expect(error).to include('Incomplete event')
|
|
253
|
+
expect(File).not_to exist(File.join(directory, 'selection.json'))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it 'keeps the hosted workflow always-created, read-only and bounded' do
|
|
258
|
+
path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
|
|
259
|
+
workflow = YAML.load_file(path)
|
|
260
|
+
triggers = workflow.fetch('on') { workflow.fetch(true) }
|
|
261
|
+
expect(triggers.keys).to contain_exactly('push', 'pull_request', 'schedule', 'workflow_dispatch')
|
|
262
|
+
expect(triggers.fetch('push')).not_to have_key('paths')
|
|
263
|
+
expect(triggers.fetch('pull_request')).to be_nil
|
|
264
|
+
expect(triggers.fetch('schedule')).to eq([{ 'cron' => '0 3 * * 0' }])
|
|
265
|
+
expect(workflow.fetch('permissions')).to eq('contents' => 'read')
|
|
266
|
+
job = workflow.fetch('jobs').fetch('performance')
|
|
267
|
+
expect(job.fetch('runs-on')).to eq('ubuntu-24.04')
|
|
268
|
+
expect(job.fetch('timeout-minutes')).to include('45', '15')
|
|
269
|
+
gate = workflow.fetch('jobs').fetch('gate')
|
|
270
|
+
expect(gate).to include('name' => 'Performance gate', 'needs' => 'performance', 'if' => 'always()')
|
|
271
|
+
expect(gate.fetch('steps').first.fetch('run')).to include('test "$PERFORMANCE_RESULT" = success')
|
|
272
|
+
upload = job.fetch('steps').find { |step| step.fetch('uses', '').start_with?('actions/upload-artifact@') }
|
|
273
|
+
expect(upload).to include('if' => 'always()')
|
|
274
|
+
expect(upload.fetch('with')).to include('retention-days' => 30, 'if-no-files-found' => 'error')
|
|
275
|
+
job.fetch('steps').select { |step| step.fetch('uses', '').start_with?('actions/checkout@') }.each do |step|
|
|
276
|
+
expect(step.fetch('with')).to include('persist-credentials' => false, 'fetch-depth' => 0)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|