haveapi 0.30.2 → 0.31.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.
@@ -42,10 +42,10 @@ describe HaveAPI::ClientExample do
42
42
  expect(last_response.body).to include('Initialization', '<h4>Ruby</h4>', '<h4>PHP</h4>', '<h4>curl</h4>')
43
43
  end
44
44
 
45
- it 'hides unconfigured Go and Rust SDKs in overview, initialization, authentication and actions' do
45
+ it 'hides unconfigured generated SDKs in overview, initialization, authentication and actions' do
46
46
  get '/v1/'
47
47
  expect(last_response.status).to eq(200)
48
- expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', '>Go</h', '>Rust (')
48
+ expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', 'typescript-generated', '>Go</h', '>Rust (')
49
49
  expect(last_response.body).to include('init-javascript-callback', 'init-typescript-async', 'init-python-sync')
50
50
  expect(last_response.body).to include('example-widget-show-0-javascript-callback')
51
51
  end
@@ -4,12 +4,12 @@ require 'open3'
4
4
  require_relative 'support/samples'
5
5
 
6
6
  describe HaveAPI::Performance::Comparator do
7
- def compare_cli(samples, budgets)
7
+ def compare_cli(samples, budgets, sample_bytes: JSON.generate(samples))
8
8
  Dir.mktmpdir('haveapi-comparator-') do |directory|
9
9
  input = File.join(directory, 'samples.json')
10
10
  policy = File.join(directory, 'budgets.json')
11
11
  output = File.join(directory, 'result')
12
- File.write(input, JSON.generate(samples))
12
+ File.binwrite(input, sample_bytes)
13
13
  File.write(policy, budgets)
14
14
  _stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
15
15
  'compare', '--input', input, '--budgets', policy, '--output', output)
@@ -25,6 +25,14 @@ describe HaveAPI::Performance::Comparator do
25
25
  output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
26
26
  end
27
27
 
28
+ def replace_response(sample, body, json: true)
29
+ replace_raw_body(sample, body)
30
+ canonical = json ? JSON.parse(body) : nil
31
+ sample['bodies'].values.first['canonical_body'] = canonical
32
+ sample['outputs'].first.merge!('envelope_status' => json ? canonical.fetch('status') : nil,
33
+ 'canonical_sha256' => json ? HaveAPI::Performance::Identity.digest(canonical) : Digest::SHA256.hexdigest(body))
34
+ end
35
+
28
36
  def rebind_source(samples, role, sha)
29
37
  samples["#{role}_sha"] = sha
30
38
  samples['sources'][role]['sha'] = sha
@@ -44,6 +52,99 @@ describe HaveAPI::Performance::Comparator do
44
52
  expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
45
53
  end
46
54
 
55
+ it 'attributes replay success and loaded-input errors to exact input/comparator bytes and the original measurement harness' do
56
+ samples, budgets = PerformanceSamples.fixture
57
+ [false, true].each do |broken|
58
+ samples['schema_version'] = broken ? 1 : 2
59
+ bytes = "#{JSON.pretty_generate(samples)}\n"
60
+ code, report = compare_cli(samples, budgets, sample_bytes: bytes)
61
+ expect(code).to eq(broken ? 3 : 0)
62
+ expect(report['harness_sha256']).to eq('e' * 64)
63
+ expect(report.values_at('baseline_sha', 'candidate_sha', 'budget_sha256')).to eq(samples.values_at('baseline_sha', 'candidate_sha', 'budget_sha256'))
64
+ expect(report['comparison']).to eq('policy' => 'per-revision-response-stability-v1',
65
+ 'comparator_sha256' => Digest::SHA256.file(File.expand_path('../../benchmarks/performance_compare.rb', __dir__)).hexdigest,
66
+ 'samples_sha256' => Digest::SHA256.hexdigest(bytes))
67
+ end
68
+ code, report = compare_cli(samples, budgets, sample_bytes: '{')
69
+ expect(code).to eq(3)
70
+ expect(report).not_to have_key('harness_sha256')
71
+ expect(report.dig('comparison', 'samples_sha256')).to eq(Digest::SHA256.hexdigest('{'))
72
+ end
73
+
74
+ it 'classifies stable changed JSON and HTML normally without changing performance budgets' do
75
+ samples, budgets = PerformanceSamples.fixture
76
+ samples['cases'].each do |item|
77
+ html = item['id'].start_with?('docs.')
78
+ (item['self_pairs'] + item['pairs']).each do |pair|
79
+ %w[baseline candidate].each do |role|
80
+ actual = samples['observations'][pair[role]['observation']]['source']['sha']
81
+ value = actual == samples['baseline_sha'] ? 'before' : 'after'
82
+ body = html ? "<!doctype html><html><body>#{value}</body></html>" : JSON.generate(status: true, response: value)
83
+ replace_response(pair[role], body, json: !html)
84
+ end
85
+ end
86
+ end
87
+ code, report = compare_cli(samples, budgets)
88
+ expect(code).to eq(0)
89
+ expect(report['status']).to eq('pass')
90
+ expect(report['cases'].map { |item| item['responses'].slice('raw_changed', 'canonical_changed') }).to all(eq('raw_changed' => true, 'canonical_changed' => true))
91
+ %w[wall_seconds cpu_seconds allocated_objects].each do |metric|
92
+ changed = Marshal.load(Marshal.dump(samples))
93
+ changed['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = metric == 'allocated_objects' ? 140 : 3.0 }
94
+ regression_code, regression_report = compare_cli(changed, budgets)
95
+ expect(regression_code).to eq(1), metric
96
+ expect(regression_report['status']).to eq('regression')
97
+ end
98
+ end
99
+
100
+ it 'rejects canonical variation within a source including the second baseline/self column' do
101
+ %w[self_pairs pairs].each do |group|
102
+ samples, budgets = PerformanceSamples.fixture
103
+ replace_response(samples['cases'].first[group].last['candidate'], '{"status":true,"response":"changed"}')
104
+ code, report = compare_cli(samples, budgets)
105
+ expect(code).to eq(3)
106
+ expected_sha = samples[group == 'self_pairs' ? 'baseline_sha' : 'candidate_sha']
107
+ expect(report.dig('error', 'message')).to eq("Response identity differs within revision #{expected_sha}")
108
+ end
109
+ end
110
+
111
+ it 'replays benchmark inputs with different unused contributor flakes and rejects tampered consumed or source inputs' do
112
+ %w[quick weekly].each do |profile|
113
+ samples, budgets = PerformanceSamples.fixture(profile:)
114
+ tool = HaveAPI::Performance::BENCHMARK_INPUTS.to_h do |path|
115
+ content = "controlled #{path}\n"
116
+ [path, { 'content' => content, 'sha256' => Digest::SHA256.hexdigest(content) }]
117
+ end
118
+ samples.fetch('observations').each_value do |observation|
119
+ role = observation.fetch('source').fetch('sha') == samples['baseline_sha'] ? 'baseline' : 'candidate'
120
+ inputs = Marshal.load(Marshal.dump(observation.fetch('inputs')))
121
+ inputs['benchmark'] = tool
122
+ inputs['locks'] = { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
123
+ inputs['contributor_flakes'] = %w[flake.nix flake.lock].to_h { |path| [path, { 'content' => "#{role} #{path}" }] }
124
+ inputs['contributor_flakes'].each do |path, input|
125
+ samples['sources'][role]['files_sha256'][path] = Digest::SHA256.hexdigest(input.fetch('content'))
126
+ end
127
+ inputs['manifests']['.ruby-version'] = Digest::SHA256.hexdigest('3.2.0')
128
+ samples['sources'][role]['files_sha256']['.ruby-version'] = inputs['manifests']['.ruby-version']
129
+ observation['inputs'] = inputs
130
+ end
131
+ samples['inputs'] = HaveAPI::Performance::Release.graph(samples['observations']['baseline']['inputs'])
132
+ expect(compare_cli(samples, budgets).first).to eq(0)
133
+ %w[tool lock raw-flake declaration ruby].each do |fault|
134
+ changed = Marshal.load(Marshal.dump(samples))
135
+ inputs = changed['observations']['candidate']['inputs']
136
+ case fault
137
+ when 'tool' then inputs['benchmark']['servers/ruby/benchmarks/shell.nix']['content'] << 'tampered'
138
+ when 'lock' then inputs['locks']['servers/ruby/benchmarks/Gemfile.lock'] = 'f' * 64
139
+ when 'raw-flake' then inputs['contributor_flakes']['flake.nix']['content'] << 'tampered'
140
+ when 'declaration' then inputs['manifests']['servers/ruby/Gemfile'] = 'f' * 64
141
+ else inputs['manifests']['.ruby-version'] = 'f' * 64
142
+ end
143
+ expect(compare_cli(changed, budgets).first).to eq(3), "#{profile}:#{fault}"
144
+ end
145
+ end
146
+ end
147
+
47
148
  it 'rejects unsupported sample formats without converting historical evidence' do
48
149
  samples, budgets = PerformanceSamples.fixture
49
150
  samples['schema_version'] = 1
@@ -170,7 +271,7 @@ describe HaveAPI::Performance::Comparator do
170
271
  end
171
272
  end
172
273
 
173
- it 'compares response occurrence totals across revisions without retaining request order' do
274
+ it 'compares response occurrence totals within each revision without retaining request order' do
174
275
  samples, json = PerformanceSamples.fixture
175
276
  spec = samples['cases'].first
176
277
  spec['iterations'] = 3
@@ -178,7 +279,7 @@ describe HaveAPI::Performance::Comparator do
178
279
  %w[baseline candidate].each do |role|
179
280
  sample = pair[role]
180
281
  output = sample['outputs'].first
181
- body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":1')
282
+ body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":100')
182
283
  canonical = JSON.parse(body)
183
284
  digest = Digest::SHA256.hexdigest(body)
184
285
  sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
@@ -189,9 +290,16 @@ describe HaveAPI::Performance::Comparator do
189
290
  end
190
291
  end
191
292
  comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
192
- expect(comparator.compare['status']).to eq('pass')
293
+ report = comparator.compare
294
+ expect(report['status']).to eq('pass')
295
+ original_bytes = spec['pairs'].first['baseline']['outputs'].first['body_bytes']
296
+ expect(report['cases'].first.dig('responses', 'bytes_per_request', 'baseline')).to be_within(0.00001).of(original_bytes + (4.0 / 3))
193
297
  spec['pairs'].last['candidate']['outputs'].reverse!
194
298
  expect(comparator.compare['status']).to eq('pass')
299
+ spec['pairs'].each { |pair| pair['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] } }
300
+ report = comparator.compare
301
+ expect(report['status']).to eq('pass')
302
+ expect(report['cases'].first.dig('responses', 'canonical_changed')).to be(true)
195
303
  spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
196
304
  expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
197
305
  end
@@ -226,7 +334,29 @@ describe HaveAPI::Performance::Comparator do
226
334
  body = JSON.parse(target['bodies'].values.first['body'])
227
335
  body['response'] = body['response'].to_a.reverse.to_h
228
336
  replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
229
- expect(compare_cli(samples, budgets).first).to eq(0)
337
+ code, report = compare_cli(samples, budgets)
338
+ expect(code).to eq(0)
339
+ expect(report['cases'].first['responses']).to include('raw_changed' => true, 'canonical_changed' => false)
340
+ expect(report['cases'].first.dig('responses', 'bytes_per_request', 'delta')).to eq(0)
341
+ end
342
+
343
+ it 'reports weighted byte deltas from baseline/candidate pairs without self-pair bias or a raw determinism requirement' do
344
+ samples, budgets = PerformanceSamples.fixture
345
+ item = samples['cases'].first
346
+ baseline_bytes = item['pairs'].first['baseline']['outputs'].first['body_bytes']
347
+ item['self_pairs'].each do |pair|
348
+ %w[baseline candidate].each { |role| replace_raw_body(pair[role], " #{pair[role]['bodies'].values.first['body']}") }
349
+ end
350
+ item['pairs'].last(2).each do |pair|
351
+ replace_raw_body(pair['candidate'], " #{pair['candidate']['bodies'].values.first['body']}")
352
+ end
353
+ code, report = compare_cli(samples, budgets)
354
+ expect(code).to eq(0)
355
+ response = report['cases'].first['responses']
356
+ expect(response).to include('raw_changed' => true, 'canonical_changed' => false)
357
+ expect(response['bytes_per_request']).to include('baseline' => baseline_bytes, 'candidate' => baseline_bytes + 0.8)
358
+ expect(response['bytes_per_request']['delta']).to be_within(0.00001).of(0.8)
359
+ expect(response['bytes_per_request']['delta_percent']).to be_within(0.00001).of(80.0 / baseline_bytes)
230
360
  end
231
361
 
232
362
  it 'rejects identical source SHAs even with consistently rebound provenance' do
@@ -3,6 +3,7 @@ require 'open3'
3
3
  require 'tmpdir'
4
4
  require 'yaml'
5
5
  require_relative '../../../../utils/performance_ci'
6
+ require_relative 'support/samples'
6
7
 
7
8
  RSpec.describe HaveAPI::PerformanceCI do
8
9
  let(:candidate) { sha('c') }
@@ -43,27 +44,111 @@ RSpec.describe HaveAPI::PerformanceCI do
43
44
  expect(result).to include(candidate_sha: candidate, baseline_sha: base, profile: 'quick', relevant: true)
44
45
  end
45
46
 
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)
47
+ %w[master feature].each do |branch|
48
+ it "compares the exact #{branch} push batch against event before" do
49
+ event.merge!('ref' => "refs/heads/#{branch}", 'after' => candidate, 'before' => base)
50
+ allow(repository).to receive(:merge_base)
51
+
52
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
53
+ reason: 'push event before/after batch',
54
+ push_pair: { 'event_name' => 'push', 'before' => base, 'after' => candidate })
55
+ expect(repository).to have_received(:paths).with(base, candidate)
56
+ expect(repository).not_to have_received(:merge_base)
57
+ end
58
+
59
+ it "permits a nonancestor #{branch} rewrite only for the exact pushed pair" do
60
+ event.merge!('ref' => "refs/heads/#{branch}", 'after' => candidate, 'before' => base, 'forced' => true)
61
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
62
+ allow(repository).to receive(:merge_base)
49
63
 
50
- expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate)
51
- expect(repository).not_to have_received(:merge_base)
64
+ result = resolver.resolve(event_name: 'push', event:, event_sha: sha('e'))
65
+ expect(result).to include(baseline_sha: base, candidate_sha: candidate,
66
+ push_pair: { 'event_name' => 'push', 'before' => base, 'after' => candidate })
67
+ expect(repository).to have_received(:paths).with(base, candidate)
68
+ expect(repository).not_to have_received(:merge_base)
69
+ end
52
70
  end
53
71
 
54
- [false, true].each do |new_branch|
55
- it "uses the whole feature merge-base for a #{new_branch ? 'new' : 'subsequent'} push" do
72
+ [false, true].each do |created|
73
+ it "uses the captured default merge-base for a zero-before push with created=#{created}" do
56
74
  event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
57
- 'before' => new_branch ? '0' * 40 : sha('a'), 'created' => new_branch)
75
+ 'before' => '0' * 40, 'created' => created)
58
76
  allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
59
77
 
60
- expect(resolve('push', event, default_sha: default)).to include(baseline_sha: base, captured_default_sha: default)
78
+ result = resolve('push', event, default_sha: default)
79
+ expect(result).to include(baseline_sha: base, captured_default_sha: default,
80
+ reason: 'new-branch merge-base against captured default')
81
+ expect(result[:push_pair]).to be_nil
82
+ expect(repository).to have_received(:paths).with(base, candidate)
61
83
  end
62
84
  end
63
85
 
64
- it 'rejects initial default-branch pushes without an available predecessor' do
86
+ it 'does not guess a fallback when the captured default commit is unavailable' do
87
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate, 'before' => '0' * 40)
88
+ allow(repository).to receive(:commit!).with(default).and_raise(described_class::Error, 'unavailable default')
89
+ expect { resolve('push', event, default_sha: default) }.to raise_error(described_class::Error, /unavailable default/)
90
+ end
91
+
92
+ it 'rejects a new branch whose captured-default merge-base equals its head' do
65
93
  event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
94
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
95
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /differ/)
96
+ end
97
+
98
+ it 'does not replace an unavailable new-branch merge-base with another commit' do
99
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate, 'before' => '0' * 40)
100
+ allow(repository).to receive(:merge_base).with(candidate, default).and_raise(described_class::Error, 'no merge-base')
101
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /no merge-base/)
102
+ end
103
+
104
+ it 'requires ancestry for the new-branch fallback instead of granting a push-pair exception' do
105
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate, 'before' => '0' * 40)
106
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
107
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
108
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /ancestor/)
109
+ end
110
+
111
+ it 'does not grant the quick push exception to a weekly push profile' do
112
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate, 'before' => described_class::REFERENCE,
113
+ 'inputs' => { 'profile' => 'weekly' })
114
+ allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
115
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /ancestor/)
116
+ end
117
+
118
+ %w[before after].each do |endpoint|
119
+ [nil, '', 'short', 'A' * 40].each do |invalid|
120
+ it "rejects an invalid push #{endpoint} #{invalid.inspect}" do
121
+ event.merge!('ref' => 'refs/heads/feature', 'before' => base, 'after' => candidate, endpoint => invalid)
122
+ expect { resolve('push', event) }.to raise_error(described_class::Error)
123
+ expect(repository).not_to have_received(:paths)
124
+ end
125
+ end
126
+
127
+ it "rejects a missing push #{endpoint}" do
128
+ event.merge!('ref' => 'refs/heads/feature', 'before' => base, 'after' => candidate)
129
+ event.delete(endpoint)
130
+ expect { resolve('push', event) }.to raise_error(described_class::Error)
131
+ expect(repository).not_to have_received(:paths)
132
+ end
133
+
134
+ %w[noncommit unavailable].each do |failure|
135
+ it "rejects a #{failure} push #{endpoint} without substituting a merge-base" do
136
+ event.merge!('ref' => 'refs/heads/feature', 'before' => base, 'after' => candidate)
137
+ allow(repository).to receive(:commit!).with(event.fetch(endpoint)).and_raise(described_class::Error, failure)
138
+ allow(repository).to receive(:merge_base)
139
+ expect { resolve('push', event) }.to raise_error(described_class::Error, failure)
140
+ expect(repository).not_to have_received(:merge_base)
141
+ expect(repository).not_to have_received(:paths)
142
+ end
143
+ end
144
+ end
145
+
146
+ it 'rejects zero after and equal push endpoints' do
147
+ event.merge!('ref' => 'refs/heads/feature', 'before' => base, 'after' => '0' * 40)
66
148
  expect { resolve('push', event) }.to raise_error(described_class::Error)
149
+ event['after'] = base
150
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /differ/)
151
+ expect(repository).not_to have_received(:paths)
67
152
  end
68
153
 
69
154
  context 'with the verified maintenance lineage' do
@@ -83,22 +168,28 @@ RSpec.describe HaveAPI::PerformanceCI do
83
168
  it 'compares the complete maintenance push batch using event before' do
84
169
  event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
85
170
  expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
86
- release_lineage: lineage, reason: 'maintenance event before/after batch')
171
+ release_lineage: lineage, reason: 'push event before/after batch',
172
+ push_pair: { 'event_name' => 'push', 'before' => base, 'after' => candidate })
87
173
  end
88
174
 
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
175
+ it 'permits a proven maintenance rewrite with the exact push pair' do
176
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate, 'forced' => true)
177
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
178
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate, release_lineage: lineage,
179
+ push_pair: { 'event_name' => 'push', 'before' => base, 'after' => candidate })
96
180
  end
97
181
 
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/)
182
+ it 'uses exact before for an existing maintenance feature without looking up a release head' do
183
+ event.merge!('ref' => 'refs/heads/feature', 'before' => base, 'after' => candidate)
184
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate, release_lineage: lineage)
185
+ expect(repository).not_to have_received(:default_sha).with('haveapi-0.30')
186
+ end
187
+
188
+ it 'uses the captured default merge-base for a new maintenance feature' do
189
+ event.merge!('ref' => 'refs/heads/feature', 'before' => '0' * 40, 'after' => candidate)
190
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
191
+ expect(resolve('push', event)).to include(baseline_sha: base, captured_default_sha: default, release_lineage: lineage)
192
+ expect(repository).not_to have_received(:default_sha).with('haveapi-0.30')
102
193
  end
103
194
 
104
195
  it 'accepts only the original weekly reference through the proven mapping' do
@@ -137,6 +228,15 @@ RSpec.describe HaveAPI::PerformanceCI do
137
228
  expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /differ/)
138
229
  end
139
230
 
231
+ it 'does not extend the push exception to pull requests or schedules' do
232
+ event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
233
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
234
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
235
+ expect { resolve('pull_request', event) }.to raise_error(described_class::Error, /ancestor/)
236
+ allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
237
+ expect { resolve('schedule', event) }.to raise_error(described_class::Error, /ancestor/)
238
+ end
239
+
140
240
  it 'uses the exact scheduled event SHA and preserves fixed reference ancestry' do
141
241
  expect(resolve('schedule', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
142
242
  expect(repository).to have_received(:ancestor?).with(described_class::REFERENCE, candidate)
@@ -178,6 +278,7 @@ RSpec.describe HaveAPI::PerformanceCI do
178
278
 
179
279
  it 'detects configuration, dependencies, harness and workflow changes' do
180
280
  %w[flake.lock Gemfile Makefile .ruby-version servers/ruby/benchmarks/endpoints.rb
281
+ servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix
181
282
  servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
182
283
  doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
183
284
  expect(described_class::RELEVANT).to match(path)
@@ -206,20 +307,30 @@ RSpec.describe HaveAPI::PerformanceCI do
206
307
  end
207
308
  end
208
309
 
209
- [0, 1, 3].each do |code|
210
- it "preserves actual runner exit #{code} with a matching report" do
310
+ [[0, 'pass', false], [0, 'inconclusive', false], [1, 'regression', false], [3, 'error', false], [0, 'pass', true]].each do |code, outcome, rewrite|
311
+ it "preserves actual runner exit #{code}/#{outcome} and writes its summary#{' for a nonancestor push' if rewrite}" do
211
312
  Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
212
313
  checkout = File.join(directory, 'checkout')
213
314
  FileUtils.mkdir_p(File.join(checkout, 'servers/ruby/benchmarks'))
315
+ samples, budget_json = PerformanceSamples.fixture
316
+ report = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budget_json), budget_sha256: samples['budget_sha256']).compare
317
+ report.merge!('status' => outcome, 'baseline_sha' => base, 'candidate_sha' => candidate)
318
+ item = report['cases'].first
319
+ item['classification'] = outcome
320
+ item['timing']['wall_seconds']['classification'] = outcome
321
+ item['responses']['raw_changed'] = true
322
+ item['responses']['bytes_per_request'].merge!('baseline' => 100.0, 'candidate' => 112.0, 'delta' => 12.0, 'delta_percent' => 12.0)
323
+ summary_path = File.join(directory, 'summary.md')
324
+ allow(ENV).to receive(:[]).and_call_original
325
+ allow(ENV).to receive(:fetch).and_call_original
326
+ allow(ENV).to receive(:[]).with('GITHUB_STEP_SUMMARY').and_return(summary_path)
327
+ allow(ENV).to receive(:fetch).with('GITHUB_STEP_SUMMARY').and_return(summary_path)
214
328
  script = <<~RUBY
215
329
  require 'json'
216
330
  require 'fileutils'
217
331
  output = ARGV.fetch(ARGV.index('--output') + 1)
218
332
  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
- ))
333
+ File.write(File.join(output, 'report.json'), #{JSON.generate(report).dump})
223
334
  exit #{code}
224
335
  RUBY
225
336
  File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
@@ -228,6 +339,10 @@ RSpec.describe HaveAPI::PerformanceCI do
228
339
  allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
229
340
  selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
230
341
  'baseline_sha' => base, 'candidate_sha' => candidate }
342
+ if rewrite
343
+ selection.merge!('event_name' => 'push', 'push_pair' => { 'event_name' => 'push', 'before' => base, 'after' => candidate })
344
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
345
+ end
231
346
  campaign = described_class::Campaign.new(repository:, selection:,
232
347
  evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
233
348
  allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
@@ -235,6 +350,19 @@ RSpec.describe HaveAPI::PerformanceCI do
235
350
  allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
236
351
  expect(campaign.run).to eq(code)
237
352
  expect(File.read(File.join(directory, 'evidence/runner.exit')).strip).to eq(code.to_s)
353
+ summary = File.read(summary_path)
354
+ expect(summary).to include("Performance comparison: #{outcome}", base, candidate)
355
+ if outcome == 'error'
356
+ expect(summary).not_to include('Bytes/request')
357
+ else
358
+ expect(summary).to include("| api.public_get {} | #{outcome} | #{outcome} | pass | pass | true | false | 100.00 → 112.00 | +12.00 (+12.00%) |")
359
+ end
360
+ if rewrite
361
+ push_event = File.join(directory, 'evidence/push-event.json')
362
+ expect(JSON.parse(File.read(push_event))).to eq(selection)
363
+ command = JSON.parse(File.read(File.join(directory, 'evidence/runner-command.json')))
364
+ expect(command.last(2)).to eq(['--push-event', push_event])
365
+ end
238
366
  end
239
367
  end
240
368
  end
@@ -76,7 +76,7 @@ describe HaveAPI::Performance::Release do
76
76
 
77
77
  def contents(version, profile: 'quick')
78
78
  original = described_class.paths(profile).to_h { |path| [path, File.read(File.join(root, path))] }
79
- own = described_class.version(original)
79
+ own = original.fetch('VERSION').strip
80
80
  original.to_h do |path, value|
81
81
  value = "#{version}\n" if path == 'VERSION'
82
82
  value = value.sub("VERSION = '#{own}'", "VERSION = '#{version}'") if described_class::VERSIONS.has_key?(path)
@@ -85,6 +85,16 @@ describe HaveAPI::Performance::Release do
85
85
  end
86
86
  end
87
87
 
88
+ def with_maintenance_source
89
+ Dir.mktmpdir('haveapi-maintenance-source-') do |source|
90
+ contents('0.30.2').each do |path, value|
91
+ FileUtils.mkdir_p(File.dirname(File.join(source, path)))
92
+ File.write(File.join(source, path), value)
93
+ end
94
+ yield source
95
+ end
96
+ end
97
+
88
98
  it 'verifies the complete published binary delta and all four exact trees in an owned history' do
89
99
  expect(proof).to include('reference_sha' => HaveAPI::Performance::REFERENCE, 'mapped_sha' => described_class::MAPPED,
90
100
  'metadata_sha' => described_class::METADATA, 'trees' => described_class::TREES)
@@ -150,6 +160,8 @@ describe HaveAPI::Performance::Release do
150
160
  '--baseline-root', root, '--candidate-root', root,
151
161
  '--budgets', 'unused', '--output', 'unused'])
152
162
  cli.send(:parse)
163
+ allow(cli).to receive(:verify_runtime)
164
+ allow(HaveAPI::Performance).to receive(:gemspec)
153
165
  allow(cli).to receive(:verify_source) { |role| { root: "/controlled/#{role}", sha: head } }
154
166
  allow(cli).to receive(:git) { |*arguments| repository.git(*arguments) }
155
167
  allow(cli).to receive(:ancestor?) { |left, right| repository.ancestor?(left, right) }
@@ -190,7 +202,20 @@ describe HaveAPI::Performance::Release do
190
202
  end
191
203
  end
192
204
 
205
+ it 'builds maintenance fixtures from a later minor source template without broadening the release guard' do
206
+ Dir.mktmpdir('haveapi-later-minor-template-') do |template|
207
+ contents('0.32.0').each do |path, value|
208
+ FileUtils.mkdir_p(File.dirname(File.join(template, path)))
209
+ File.write(File.join(template, path), value)
210
+ end
211
+ allow(self).to receive(:root).and_return(template)
212
+ expect(described_class.capture(contents('0.30.2')).fetch('own_version')).to eq('0.30.2')
213
+ expect { described_class.capture(contents('0.32.0')) }.to raise_error(error, /Expected own 0\.30 patch version/)
214
+ end
215
+ end
216
+
193
217
  it 'rejects mismatched own constants, package names, constraints and minor versions' do
218
+ expect { described_class.capture(contents('0.31.0')) }.to raise_error(error, /Expected own 0\.30 patch version/)
194
219
  [
195
220
  ['VERSION', "0.31.2\n"],
196
221
  ['VERSION', " 0.30.2\n"],
@@ -211,7 +236,6 @@ describe HaveAPI::Performance::Release do
211
236
  File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
212
237
  FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
213
238
  File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
214
- stub_const('HaveAPI::RustClient::VERSION', HaveAPI::VERSION) unless defined?(HaveAPI::RustClient::VERSION)
215
239
  stub_const('HaveAPI::Performance::ROOT', harness)
216
240
  ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
217
241
  preparation = ordinary.fetch('json_preparation')
@@ -220,10 +244,23 @@ describe HaveAPI::Performance::Release do
220
244
  expect do
221
245
  described_class.validate_json_preparation(ordinary, 'files_sha256' => preparation.fetch('raw_sha256'))
222
246
  end.not_to raise_error
223
- captured = HaveAPI::Performance::Worker.inputs(root, 'quick', release: true)
224
- expect(captured[:locks]).to have_key('clients/rust/Gemfile.lock')
225
- expect(captured[:manifests]).to have_key('clients/rust/Gemfile')
226
- expect(captured.fetch(:release_versions)).to eq(described_class.capture(contents(File.read(File.join(root, 'VERSION')).strip)))
247
+ %w[HaveAPI::VERSION HaveAPI::Client::VERSION HaveAPI::RustClient::VERSION].each do |name|
248
+ stub_const(name, '0.30.2')
249
+ end
250
+ HaveAPI::Performance::BENCHMARK_INPUTS.each do |path|
251
+ FileUtils.mkdir_p(File.dirname(File.join(harness, path)))
252
+ FileUtils.cp(File.join(root, path), File.join(harness, path))
253
+ end
254
+ with_maintenance_source do |source|
255
+ captured = HaveAPI::Performance::Worker.inputs(source, 'quick', release: true)
256
+ expect(captured[:locks]).to have_key('clients/rust/Gemfile.lock')
257
+ expect(captured[:manifests]).to have_key('clients/rust/Gemfile')
258
+ expect(captured.fetch(:release_versions)).to eq(described_class.capture(contents('0.30.2')))
259
+ benchmark = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(source, 'quick', release: true, benchmark: true)))
260
+ expect(benchmark.fetch('manifests')).not_to have_key('flake.lock')
261
+ expect(benchmark.fetch('contributor_flakes')).to have_key('flake.lock')
262
+ expect(described_class.validate_inputs(benchmark, { 'files_sha256' => benchmark.fetch('release_versions').fetch('raw_sha256') }, 'quick')).to eq('0.30.2')
263
+ end
227
264
  Dir.mktmpdir('haveapi-release-inputs-') do |source|
228
265
  contents('0.30.99').each do |path, value|
229
266
  FileUtils.mkdir_p(File.dirname(File.join(source, path)))
@@ -340,7 +377,7 @@ describe HaveAPI::Performance::Release do
340
377
  end
341
378
 
342
379
  it 'normalizes the actual worker HTML outside timing and retains its raw bytes' do
343
- own = File.read(File.join(root, 'VERSION')).strip
380
+ own = '0.30.2'
344
381
  body = "<a href=\"https://webui.haveapi.org/v#{own}/#api\">Connect</a>#{framework_footer(own)}"
345
382
  scenario = HaveAPI::Performance::DocumentationScenario.allocate
346
383
  allow(scenario).to receive(:reset!)
@@ -348,13 +385,15 @@ describe HaveAPI::Performance::Release do
348
385
  validate!: { http_status: 200, envelope_status: nil, body_bytes: body.bytesize,
349
386
  body_sha256: Digest::SHA256.hexdigest(body), canonical_sha256: Digest::SHA256.hexdigest(body) },
350
387
  counters: { operations: [{ queries: 0, authorizations: 0, executions: 0 }] })
351
- worker = HaveAPI::Performance::Worker.new('source_root' => root, 'release_versions' => true)
352
- worker.instance_variable_set(:@current_workload, 'docs.html_resources')
353
- result = worker.measure(scenario, 1)
354
- output = result.fetch(:outputs).first
355
- expect(output.fetch('comparison')).to include('own_version' => File.read(File.join(root, 'VERSION')).strip)
356
- expect(result.fetch(:bodies).values.first.fetch(:body)).to eq(body)
357
- expect(output.fetch('canonical_sha256')).to eq(Digest::SHA256.hexdigest(described_class.html(body, output['comparison']['own_version'])))
388
+ with_maintenance_source do |source|
389
+ worker = HaveAPI::Performance::Worker.new('source_root' => source, 'release_versions' => true)
390
+ worker.instance_variable_set(:@current_workload, 'docs.html_resources')
391
+ result = worker.measure(scenario, 1)
392
+ output = result.fetch(:outputs).first
393
+ expect(output.fetch('comparison')).to include('own_version' => own)
394
+ expect(result.fetch(:bodies).values.first.fetch(:body)).to eq(body)
395
+ expect(output.fetch('canonical_sha256')).to eq(Digest::SHA256.hexdigest(described_class.html(body, output['comparison']['own_version'])))
396
+ end
358
397
  end
359
398
 
360
399
  it 'leaves lookalike attributes, other URLs, JSON and example code byte exact' do