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.
@@ -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