haveapi 0.30.2 → 0.31.1

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.
@@ -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)
@@ -169,7 +269,7 @@ RSpec.describe HaveAPI::PerformanceCI do
169
269
  end
170
270
 
171
271
  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'])
272
+ allow(repository).to receive(:paths).and_return(['LICENSE.txt'])
173
273
  event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
174
274
  expect(resolve('push', event)).to include(relevant: false)
175
275
  expect(resolve('schedule', event)).to include(relevant: true)
@@ -178,12 +278,40 @@ 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
- doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
283
+ doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml
284
+ servers/ruby/benchmarks/spec/client_examples_spec.rb servers/ruby/test_support/all_client_performance_fixture.rb
285
+ clients/go/lib/haveapi/go_client/documentation.rb clients/typescript/lib/haveapi/typescript_client/documentation.rb
286
+ clients/js/src/client.ts clients/python/haveapi/client.py clients/php/src/Client.php clients/new-client/renderer.rb].each do |path|
183
287
  expect(described_class::RELEVANT).to match(path)
184
288
  end
185
289
  end
186
290
 
291
+ it 'requires the current-client contract before comparison and rejects a missing contract outcome' do
292
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
293
+ steps = YAML.load_file(path).fetch('jobs').fetch('performance').fetch('steps')
294
+ contract = steps.find { |step| step['id'] == 'clients' }
295
+ comparison = steps.find { |step| step['name'] == 'Run the selected pair outside Bundler' }
296
+ expect(steps.index(contract)).to be < steps.index(comparison)
297
+ expect(contract.fetch('if')).to eq(comparison.fetch('if'))
298
+ expect(contract.fetch('run')).to include('make test-performance-clients', "echo 'outcome=passed'")
299
+ completion = steps.find { |step| step['id'] == 'completion' }
300
+ Dir.mktmpdir('haveapi-client-contract-gate-') do |directory|
301
+ File.write(File.join(directory, 'selection.json'), '{}')
302
+ File.write(File.join(directory, 'runner.exit'), "0\n")
303
+ FileUtils.mkdir_p(File.join(directory, 'results'))
304
+ File.write(File.join(directory, 'results/report.json'), '{}')
305
+ File.write(File.join(directory, 'all-client-contract.log'), 'passed')
306
+ [nil, '', 'failed', 'passed'].each do |outcome|
307
+ environment = { 'RELEVANT' => 'true', 'CLIENT_CONTRACT' => outcome,
308
+ 'PERFORMANCE_EVIDENCE' => directory, 'GITHUB_OUTPUT' => File.join(directory, 'output') }
309
+ _output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
310
+ expect(status.success?).to eq(outcome == 'passed')
311
+ end
312
+ end
313
+ end
314
+
187
315
  it 'only accepts successful job completion in the aggregate gate' do
188
316
  expect(described_class.aggregate!('success', 'compared')).to be(true)
189
317
  expect(described_class.aggregate!('success', 'no-work')).to be(true)
@@ -206,20 +334,30 @@ RSpec.describe HaveAPI::PerformanceCI do
206
334
  end
207
335
  end
208
336
 
209
- [0, 1, 3].each do |code|
210
- it "preserves actual runner exit #{code} with a matching report" do
337
+ [[0, 'pass', false], [0, 'inconclusive', false], [1, 'regression', false], [3, 'error', false], [0, 'pass', true]].each do |code, outcome, rewrite|
338
+ it "preserves actual runner exit #{code}/#{outcome} and writes its summary#{' for a nonancestor push' if rewrite}" do
211
339
  Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
212
340
  checkout = File.join(directory, 'checkout')
213
341
  FileUtils.mkdir_p(File.join(checkout, 'servers/ruby/benchmarks'))
342
+ samples, budget_json = PerformanceSamples.fixture
343
+ report = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budget_json), budget_sha256: samples['budget_sha256']).compare
344
+ report.merge!('status' => outcome, 'baseline_sha' => base, 'candidate_sha' => candidate)
345
+ item = report['cases'].first
346
+ item['classification'] = outcome
347
+ item['timing']['wall_seconds']['classification'] = outcome
348
+ item['responses']['raw_changed'] = true
349
+ item['responses']['bytes_per_request'].merge!('baseline' => 100.0, 'candidate' => 112.0, 'delta' => 12.0, 'delta_percent' => 12.0)
350
+ summary_path = File.join(directory, 'summary.md')
351
+ allow(ENV).to receive(:[]).and_call_original
352
+ allow(ENV).to receive(:fetch).and_call_original
353
+ allow(ENV).to receive(:[]).with('GITHUB_STEP_SUMMARY').and_return(summary_path)
354
+ allow(ENV).to receive(:fetch).with('GITHUB_STEP_SUMMARY').and_return(summary_path)
214
355
  script = <<~RUBY
215
356
  require 'json'
216
357
  require 'fileutils'
217
358
  output = ARGV.fetch(ARGV.index('--output') + 1)
218
359
  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
- ))
360
+ File.write(File.join(output, 'report.json'), #{JSON.generate(report).dump})
223
361
  exit #{code}
224
362
  RUBY
225
363
  File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
@@ -228,6 +366,10 @@ RSpec.describe HaveAPI::PerformanceCI do
228
366
  allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
229
367
  selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
230
368
  'baseline_sha' => base, 'candidate_sha' => candidate }
369
+ if rewrite
370
+ selection.merge!('event_name' => 'push', 'push_pair' => { 'event_name' => 'push', 'before' => base, 'after' => candidate })
371
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
372
+ end
231
373
  campaign = described_class::Campaign.new(repository:, selection:,
232
374
  evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
233
375
  allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
@@ -235,6 +377,19 @@ RSpec.describe HaveAPI::PerformanceCI do
235
377
  allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
236
378
  expect(campaign.run).to eq(code)
237
379
  expect(File.read(File.join(directory, 'evidence/runner.exit')).strip).to eq(code.to_s)
380
+ summary = File.read(summary_path)
381
+ expect(summary).to include("Performance comparison: #{outcome}", base, candidate)
382
+ if outcome == 'error'
383
+ expect(summary).not_to include('Bytes/request')
384
+ else
385
+ expect(summary).to include("| api.public_get {} | #{outcome} | #{outcome} | pass | pass | true | false | 100.00 → 112.00 | +12.00 (+12.00%) |")
386
+ end
387
+ if rewrite
388
+ push_event = File.join(directory, 'evidence/push-event.json')
389
+ expect(JSON.parse(File.read(push_event))).to eq(selection)
390
+ command = JSON.parse(File.read(File.join(directory, 'evidence/runner-command.json')))
391
+ expect(command.last(2)).to eq(['--push-event', push_event])
392
+ end
238
393
  end
239
394
  end
240
395
  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
@@ -3,6 +3,9 @@ require 'open3'
3
3
  require 'tmpdir'
4
4
  require 'fileutils'
5
5
  require_relative '../../benchmarks/endpoints'
6
+ require_relative '../../../../utils/performance_ci'
7
+ require_relative '../../../../clients/ruby/lib/haveapi/client/version'
8
+ require_relative '../../../../clients/rust/lib/haveapi/rust_client/version'
6
9
  require_relative 'support/samples'
7
10
  require_relative '../../test_support/performance_endpoint_fixture'
8
11
  require_relative '../../test_support/documentation_performance_fixture'
@@ -109,6 +112,70 @@ describe HaveAPI::Performance::Worker do
109
112
  end
110
113
  end
111
114
 
115
+ it 'retains the single benchmark bundle and raw unused contributor flakes without library locks' do
116
+ root = File.expand_path('../../../..', __dir__)
117
+ inputs = JSON.parse(JSON.generate(described_class.inputs(root, 'quick', benchmark: true)))
118
+ tool = inputs.fetch('benchmark')
119
+ expect(tool.keys).to match_array(HaveAPI::Performance::BENCHMARK_INPUTS)
120
+ expect(inputs.fetch('locks')).to eq('servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256'))
121
+ expect(inputs.fetch('manifests')).to have_key('.ruby-version')
122
+ expect(inputs.fetch('manifests')).not_to have_key('flake.lock')
123
+ expect(inputs.fetch('contributor_flakes').keys).to match_array(%w[flake.nix flake.lock])
124
+ tool.each do |path, input|
125
+ expect(input.fetch('content')).to eq(File.read(File.join(root, path)))
126
+ expect(input.fetch('sha256')).to eq(Digest::SHA256.hexdigest(input.fetch('content')))
127
+ end
128
+ end
129
+
130
+ it 'rejects changed Ruby and unavailable calibrated dependencies before starting workers' do
131
+ samples, json = PerformanceSamples.fixture
132
+ Dir.mktmpdir('haveapi-runtime-guard-') do |directory|
133
+ budget = File.join(directory, 'budgets.json')
134
+ cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', samples['baseline_sha'],
135
+ '--candidate-root', directory, '--candidate-sha', samples['candidate_sha'],
136
+ '--budgets', budget, '--output', File.join(directory, 'result')])
137
+ cli.send(:parse)
138
+ File.write(budget, json)
139
+ allow(cli).to receive(:child)
140
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError, 'Calibration Ruby mismatch')
141
+ budgets = JSON.parse(json)
142
+ budgets['environment']['weekly']['ruby'] = JSON.parse(JSON.generate(described_class.new({}).environment.fetch(:ruby)))
143
+ budgets['environment']['weekly']['gems'] = { 'haveapi-missing-calibrated-gem' => { 'version' => '99.0', 'platform' => 'ruby' } }
144
+ File.write(budget, JSON.generate(budgets))
145
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError, /Missing calibrated dependency/)
146
+ expect(cli).not_to have_received(:child)
147
+ end
148
+ end
149
+
150
+ it 'limits the nonancestor runner exception to an exact quick push event pair' do
151
+ samples, = PerformanceSamples.fixture
152
+ Dir.mktmpdir('haveapi-push-guard-') do |directory|
153
+ event = File.join(directory, 'selection.json')
154
+ selection = samples.slice('baseline_sha', 'candidate_sha').merge('event_name' => 'push', 'profile' => 'quick',
155
+ 'push_pair' => { 'event_name' => 'push', 'before' => samples['baseline_sha'], 'after' => samples['candidate_sha'] })
156
+ %w[valid changed missing weekly].each do |control|
157
+ value = Marshal.load(Marshal.dump(selection))
158
+ value['push_pair']['before'] = 'c' * 40 if control == 'changed'
159
+ value.delete('push_pair') if control == 'missing'
160
+ File.write(event, JSON.generate(value))
161
+ cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', samples['baseline_sha'],
162
+ '--candidate-root', directory, '--candidate-sha', samples['candidate_sha'],
163
+ '--profile', control == 'weekly' ? 'weekly' : 'quick', '--push-event', event,
164
+ '--budgets', '/unused', '--output', '/unused'])
165
+ cli.send(:parse)
166
+ allow(cli).to receive(:verify_runtime)
167
+ allow(HaveAPI::Performance).to receive(:gemspec)
168
+ allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role) }
169
+ allow(cli).to receive(:ancestor?).and_return(false)
170
+ allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
171
+ allow(cli).to receive(:child).and_raise(HaveAPI::Performance::ContractError, 'pilot boundary')
172
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError,
173
+ control == 'valid' ? 'pilot boundary' : /push|Push|Weekly/)
174
+ expect(cli).not_to have_received(:child) unless control == 'valid'
175
+ end
176
+ end
177
+ end
178
+
112
179
  it 'validates every response after the timed loop' do
113
180
  scenario = Class.new do
114
181
  attr_reader :calls, :validations
@@ -252,6 +319,8 @@ describe HaveAPI::Performance::Worker do
252
319
  '--candidate-root', '/controlled/candidate', '--candidate-sha', samples['candidate_sha'],
253
320
  '--budgets', '/unused', '--output', '/unused'])
254
321
  cli.send(:parse)
322
+ allow(cli).to receive(:verify_runtime)
323
+ allow(HaveAPI::Performance).to receive(:gemspec)
255
324
  allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role) }
256
325
  allow(cli).to receive(:ancestor?).and_return(true)
257
326
  allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
@@ -354,6 +423,8 @@ describe HaveAPI::Performance::Worker do
354
423
  '--candidate-sha', samples['candidate_sha'], '--budgets', policy, '--output', output])
355
424
  # Control source verification and worker replies, retaining the real run
356
425
  # assembly, child protocol validation, direct comparator and report path.
426
+ allow(cli).to receive(:verify_runtime)
427
+ allow(HaveAPI::Performance).to receive(:gemspec)
357
428
  allow(cli).to receive_messages(git: '', ancestor?: true)
358
429
  allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
359
430
  allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role).transform_keys(&:to_sym) }
@@ -387,6 +458,9 @@ describe HaveAPI::Performance::Worker do
387
458
  expect(cli.execute).to eq(0)
388
459
  retained = JSON.parse(File.read(File.join(output, 'samples.json')))
389
460
  expect(retained['sources']).to eq(samples['sources'])
461
+ saved_report = JSON.parse(File.read(File.join(output, 'report.json')))
462
+ expect(saved_report.dig('comparison', 'samples_sha256')).to eq(Digest::SHA256.file(File.join(output, 'samples.json')).hexdigest)
463
+ expect(saved_report['harness_sha256']).to eq(retained['harness_sha256'])
390
464
  report = HaveAPI::Performance::Comparator.new(retained, budgets, budget_sha256: Digest::SHA256.hexdigest(budget_json)).compare
391
465
  expect(report['status']).to eq('pass')
392
466
  expect(requests.map(&:first).count('calibration-adjusted')).to eq(expensive ? 0 : 1)
@@ -463,14 +537,76 @@ describe HaveAPI::Performance::Worker do
463
537
  cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', '0' * 40,
464
538
  '--candidate-root', directory, '--candidate-sha', '1' * 40,
465
539
  '--budgets', budget, '--output', missing])
540
+ allow(cli).to receive(:verify_runtime)
466
541
  expect(cli.execute).to eq(3)
467
542
  report = JSON.parse(File.read(File.join(missing, 'report.json')))
468
543
  expect(report['error']['message']).to include('Source SHA must be full immutable SHA')
469
544
  expect(report.values_at('baseline_sha', 'candidate_sha')).to eq(['0' * 40, '1' * 40])
545
+ expect(report).not_to have_key('harness_sha256')
546
+ expect(report.fetch('comparison')).not_to have_key('samples_sha256')
470
547
  equal = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', 'a' * 40,
471
548
  '--candidate-root', directory, '--candidate-sha', 'a' * 40,
472
549
  '--budgets', budget, '--output', File.join(directory, 'equal')])
473
550
  expect(equal.execute).to eq(3)
474
551
  end
475
552
  end
553
+
554
+ it 'checks declared Ruby and runtime requirements against installed tools including unloaded rake' do
555
+ campaign = HaveAPI::PerformanceCI::Campaign.new(repository: instance_double(HaveAPI::PerformanceCI::Repository),
556
+ selection: {}, evidence: '/unused', sources: '/unused')
557
+ spec = { 'ruby' => '>= 3.2', 'dependencies' => [['rake', 'runtime', '>= 13.0'], ['haveapi-client', 'runtime', '~> 0.30.0']] }
558
+ status = instance_double(Process::Status, success?: true)
559
+ allow(Open3).to receive(:capture3).and_return([JSON.generate(spec), '', status])
560
+ expect(campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec')).to eq(spec)
561
+ changed = spec.merge('ruby' => '>= 99')
562
+ allow(Open3).to receive(:capture3).and_return([JSON.generate(changed), '', status])
563
+ expect { campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec') }.to raise_error(HaveAPI::PerformanceCI::Error, /Benchmark Ruby/)
564
+ changed = spec.merge('dependencies' => [['rake', 'runtime', '>= 99']])
565
+ allow(Open3).to receive(:capture3).and_return([JSON.generate(changed), '', status])
566
+ expect { campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec') }.to raise_error(HaveAPI::PerformanceCI::Error, /Benchmark dependencies/)
567
+ end
568
+
569
+ it 'rejects unsupported Ruby or gem requirements in either direct source before the first child' do
570
+ samples, = PerformanceSamples.fixture
571
+ Dir.mktmpdir('haveapi-source-requirements-') do |directory|
572
+ roots = %w[baseline candidate].to_h { |role| [role, File.join(directory, role)] }
573
+ paths = HaveAPI::Performance::Release::VERSIONS.map { |path, name| "#{path.split('/lib/').first}/#{name}.gemspec" }
574
+ valid = <<~RUBY
575
+ Gem::Specification.new do |spec|
576
+ spec.name = 'haveapi-fixture'
577
+ spec.version = '1.0.0'
578
+ spec.required_ruby_version = '>= 3.2'
579
+ spec.add_dependency 'rake', '>= 13.0'
580
+ end
581
+ RUBY
582
+ roots.each_value do |root|
583
+ paths.each do |path|
584
+ FileUtils.mkdir_p(File.dirname(File.join(root, path)))
585
+ File.write(File.join(root, path), valid)
586
+ end
587
+ end
588
+ %w[baseline candidate].product(%w[valid ruby dependency]).each do |role, fault|
589
+ path = File.join(roots.fetch(role), paths.last)
590
+ text = case fault
591
+ when 'ruby' then valid.sub('>= 3.2', '>= 99')
592
+ when 'dependency' then valid.sub('>= 13.0', '>= 99')
593
+ else valid
594
+ end
595
+ File.write(path, text)
596
+ cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', roots['baseline'], '--baseline-sha', samples['baseline_sha'],
597
+ '--candidate-root', roots['candidate'], '--candidate-sha', samples['candidate_sha'],
598
+ '--budgets', '/unused', '--output', '/unused'])
599
+ cli.send(:parse)
600
+ allow(cli).to receive(:verify_runtime)
601
+ allow(cli).to receive(:verify_source) { |name| { root: roots.fetch(name), sha: samples.fetch("#{name}_sha") } }
602
+ allow(cli).to receive(:ancestor?).and_return(true)
603
+ allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
604
+ allow(cli).to receive(:child).and_raise(HaveAPI::Performance::ContractError, 'pilot boundary')
605
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError,
606
+ fault == 'valid' ? 'pilot boundary' : /Benchmark Ruby|Benchmark dependencies/)
607
+ expect(cli).not_to have_received(:child) unless fault == 'valid'
608
+ File.write(path, valid)
609
+ end
610
+ end
611
+ end
476
612
  end