haveapi 0.30.1 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,407 @@
1
+ require 'json'
2
+ require 'open3'
3
+ require 'tmpdir'
4
+ require 'yaml'
5
+ require_relative '../../../../utils/performance_ci'
6
+ require_relative 'support/samples'
7
+
8
+ RSpec.describe HaveAPI::PerformanceCI do
9
+ let(:candidate) { sha('c') }
10
+ let(:default) { sha('d') }
11
+ let(:base) { sha('b') }
12
+ let(:event) { { 'repository' => { 'default_branch' => 'master' } } }
13
+ let(:repository) { instance_double(described_class::Repository) }
14
+
15
+ def sha(character)
16
+ character * 40
17
+ end
18
+
19
+ def resolver
20
+ described_class::Resolver.new(repository)
21
+ end
22
+
23
+ before do
24
+ allow(repository).to receive(:default_sha).with('master').and_return(default)
25
+ allow(repository).to receive(:commit!) do |value|
26
+ unless value.is_a?(String) && described_class::SHA.match?(value) && value != '0' * 40
27
+ raise described_class::Error, 'invalid commit'
28
+ end
29
+
30
+ value
31
+ end
32
+ allow(repository).to receive_messages(ancestor?: true, paths: ['servers/ruby/lib/haveapi/server.rb'], release_lineage: nil)
33
+ end
34
+
35
+ def resolve(name, data, **options)
36
+ resolver.resolve(event_name: name, event: data, event_sha: candidate, **options)
37
+ end
38
+
39
+ it 'selects the PR event head instead of its synthetic merge commit' do
40
+ event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
41
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
42
+
43
+ result = resolver.resolve(event_name: 'pull_request', event:, event_sha: sha('e'))
44
+ expect(result).to include(candidate_sha: candidate, baseline_sha: base, profile: 'quick', relevant: true)
45
+ end
46
+
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)
63
+
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
70
+ end
71
+
72
+ [false, true].each do |created|
73
+ it "uses the captured default merge-base for a zero-before push with created=#{created}" do
74
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
75
+ 'before' => '0' * 40, 'created' => created)
76
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
77
+
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)
83
+ end
84
+ end
85
+
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
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)
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)
152
+ end
153
+
154
+ context 'with the verified maintenance lineage' do
155
+ def release
156
+ sha('e')
157
+ end
158
+
159
+ def lineage
160
+ { 'candidate_sha' => candidate, 'mapped_sha' => HaveAPI::Performance::Release::MAPPED }
161
+ end
162
+
163
+ before do
164
+ allow(repository).to receive(:release_lineage).with(candidate).and_return(lineage)
165
+ allow(repository).to receive(:default_sha).with('haveapi-0.30').and_return(release)
166
+ end
167
+
168
+ it 'compares the complete maintenance push batch using event before' do
169
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
170
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
171
+ release_lineage: lineage, reason: 'push event before/after batch',
172
+ push_pair: { 'event_name' => 'push', 'before' => base, 'after' => candidate })
173
+ end
174
+
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 })
180
+ end
181
+
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')
193
+ end
194
+
195
+ it 'accepts only the original weekly reference through the proven mapping' do
196
+ event['inputs'] = { 'profile' => 'weekly', 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE }
197
+ allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
198
+ expect(resolve('workflow_dispatch', event)).to include(baseline_sha: described_class::REFERENCE, release_lineage: lineage)
199
+ event['inputs']['profile'] = 'quick'
200
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
201
+ event['inputs']['profile'] = 'weekly'
202
+ allow(repository).to receive(:release_lineage).with(candidate).and_return(nil)
203
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
204
+ end
205
+
206
+ it 'does not accept an unrelated explicit quick baseline' do
207
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
208
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
209
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
210
+ end
211
+ end
212
+
213
+ it 'rejects unproven maintenance branch history' do
214
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
215
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
216
+ end
217
+
218
+ it 'rejects deleted pushes and tag events' do
219
+ expect { resolve('push', event.merge('deleted' => true)) }.to raise_error(described_class::Error, /Deleted/)
220
+ expect { resolve('push', event.merge('ref' => 'refs/tags/v1')) }.to raise_error(described_class::Error, /branch/)
221
+ end
222
+
223
+ it 'requires a real ancestor and never substitutes a same-head baseline' do
224
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
225
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
226
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
227
+ event['inputs']['baseline_sha'] = candidate
228
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /differ/)
229
+ end
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
+
240
+ it 'uses the exact scheduled event SHA and preserves fixed reference ancestry' do
241
+ expect(resolve('schedule', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
242
+ expect(repository).to have_received(:ancestor?).with(described_class::REFERENCE, candidate)
243
+ end
244
+
245
+ it 'defaults manual runs to captured default and fixed reference' do
246
+ expect(resolve('workflow_dispatch', event)).to include(candidate_sha: default, baseline_sha: described_class::REFERENCE)
247
+ end
248
+
249
+ it 'accepts complete explicit manual SHA pairs and weekly profile' do
250
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE, 'profile' => 'weekly' }
251
+ expect(resolve('workflow_dispatch', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
252
+ event['inputs']['baseline_sha'] = base
253
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /fixed/)
254
+ end
255
+
256
+ it 'rejects partial manual pairs, invalid profiles and missing event data' do
257
+ event['inputs'] = { 'candidate_sha' => candidate }
258
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error)
259
+ event['inputs'] = { 'profile' => 'unbounded' }
260
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /profile/)
261
+ expect { resolve('pull_request', {}) }.to raise_error(described_class::Error, /Incomplete/)
262
+ expect { resolve('pull_request_target', event.merge('inputs' => {})) }.to raise_error(described_class::Error, /Unsupported/)
263
+ end
264
+
265
+ it 'does not hide unavailable commits behind a no-path success' do
266
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
267
+ allow(repository).to receive(:commit!).with(base).and_raise(described_class::Error, 'unavailable')
268
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, 'unavailable')
269
+ end
270
+
271
+ it 'skips only irrelevant valid push/PR changes and still forces scheduled/manual work' do
272
+ allow(repository).to receive(:paths).and_return(['clients/php/README.md'])
273
+ event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
274
+ expect(resolve('push', event)).to include(relevant: false)
275
+ expect(resolve('schedule', event)).to include(relevant: true)
276
+ expect(resolve('workflow_dispatch', event)).to include(relevant: true)
277
+ end
278
+
279
+ it 'detects configuration, dependencies, harness and workflow changes' do
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
282
+ servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
283
+ doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
284
+ expect(described_class::RELEVANT).to match(path)
285
+ end
286
+ end
287
+
288
+ it 'only accepts successful job completion in the aggregate gate' do
289
+ expect(described_class.aggregate!('success', 'compared')).to be(true)
290
+ expect(described_class.aggregate!('success', 'no-work')).to be(true)
291
+ %w[failure cancelled skipped unknown].each do |result|
292
+ expect { described_class.aggregate!(result, 'compared') }.to raise_error(described_class::Error)
293
+ end
294
+ [nil, '', 'unknown'].each do |outcome|
295
+ expect { described_class.aggregate!('success', outcome) }.to raise_error(described_class::Error)
296
+ end
297
+ end
298
+
299
+ it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
300
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
301
+ script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
302
+ [['success', 'compared', true], ['success', 'no-work', true], ['success', '', false],
303
+ ['failure', 'compared', false], ['cancelled', 'compared', false], ['skipped', 'no-work', false]].each do |result, outcome, accepted|
304
+ _output, _error, status = Open3.capture3({ 'PERFORMANCE_RESULT' => result, 'PERFORMANCE_OUTCOME' => outcome },
305
+ 'bash', '-e', '-c', script)
306
+ expect(status.success?).to eq(accepted), "#{result}/#{outcome}"
307
+ end
308
+ end
309
+
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
312
+ Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
313
+ checkout = File.join(directory, 'checkout')
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)
328
+ script = <<~RUBY
329
+ require 'json'
330
+ require 'fileutils'
331
+ output = ARGV.fetch(ARGV.index('--output') + 1)
332
+ FileUtils.mkdir_p(output)
333
+ File.write(File.join(output, 'report.json'), #{JSON.generate(report).dump})
334
+ exit #{code}
335
+ RUBY
336
+ File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
337
+ allow(repository).to receive(:root).and_return(checkout)
338
+ allow(repository).to receive(:git).with('rev-parse', 'HEAD').and_return(candidate)
339
+ allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
340
+ selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
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
346
+ campaign = described_class::Campaign.new(repository:, selection:,
347
+ evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
348
+ allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
349
+ allow(campaign).to receive(:export).with(:candidate, candidate).and_return(directory)
350
+ allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
351
+ expect(campaign.run).to eq(code)
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
366
+ end
367
+ end
368
+ end
369
+
370
+ it 'returns error exit 3 from the actual resolver CLI on incomplete input' do
371
+ Dir.mktmpdir('haveapi-event-spec-') do |directory|
372
+ file = File.join(directory, 'event.json')
373
+ File.write(file, '{}')
374
+ script = File.expand_path('../../../../utils/performance_ci.rb', __dir__)
375
+ _output, error, status = Open3.capture3(RbConfig.ruby, script, 'resolve', '--event', file,
376
+ '--event-name', 'pull_request', '--event-sha', candidate,
377
+ '--repository', File.expand_path('../../../..', __dir__),
378
+ '--output', File.join(directory, 'selection.json'))
379
+ expect(status.exitstatus).to eq(3)
380
+ expect(error).to include('Incomplete event')
381
+ expect(File).not_to exist(File.join(directory, 'selection.json'))
382
+ end
383
+ end
384
+
385
+ it 'keeps the hosted workflow always-created, read-only and bounded' do
386
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
387
+ workflow = YAML.load_file(path)
388
+ triggers = workflow.fetch('on') { workflow.fetch(true) }
389
+ expect(triggers.keys).to contain_exactly('push', 'pull_request', 'schedule', 'workflow_dispatch')
390
+ expect(triggers.fetch('push')).not_to have_key('paths')
391
+ expect(triggers.fetch('pull_request')).to be_nil
392
+ expect(triggers.fetch('schedule')).to eq([{ 'cron' => '0 3 * * 0' }])
393
+ expect(workflow.fetch('permissions')).to eq('contents' => 'read')
394
+ job = workflow.fetch('jobs').fetch('performance')
395
+ expect(job.fetch('runs-on')).to eq('ubuntu-24.04')
396
+ expect(job.fetch('timeout-minutes')).to include('45', '15')
397
+ gate = workflow.fetch('jobs').fetch('gate')
398
+ expect(gate).to include('name' => 'Performance gate', 'needs' => 'performance', 'if' => 'always()')
399
+ expect(gate.fetch('steps').first.fetch('run')).to include('test "$PERFORMANCE_RESULT" = success')
400
+ upload = job.fetch('steps').find { |step| step.fetch('uses', '').start_with?('actions/upload-artifact@') }
401
+ expect(upload).to include('if' => 'always()')
402
+ expect(upload.fetch('with')).to include('retention-days' => 30, 'if-no-files-found' => 'error')
403
+ job.fetch('steps').select { |step| step.fetch('uses', '').start_with?('actions/checkout@') }.each do |step|
404
+ expect(step.fetch('with')).to include('persist-credentials' => false, 'fetch-depth' => 0)
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,151 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require_relative '../../benchmarks/performance_compare'
6
+ require_relative 'support/samples'
7
+
8
+ describe HaveAPI::Performance do
9
+ let(:root) { File.expand_path('../../../..', __dir__) }
10
+ let(:comparison) { File.join(root, 'servers/ruby/benchmarks/performance_compare.rb') }
11
+
12
+ def with_newer_json
13
+ Dir.mktmpdir('haveapi-extra-json-') do |directory|
14
+ FileUtils.mkdir_p(File.join(directory, 'specifications'))
15
+ library = File.join(directory, 'gems/json-3.1.0/lib')
16
+ FileUtils.mkdir_p(library)
17
+ File.write(File.join(library, 'json.rb'), "module JSON; VERSION = '3.1.0'; end\n")
18
+ spec = Gem::Specification.new do |gem|
19
+ gem.name = 'json'
20
+ gem.version = '3.1.0'
21
+ gem.summary = 'Owned activation control'
22
+ gem.authors = ['Fixture']
23
+ end
24
+ File.write(File.join(directory, 'specifications/json-3.1.0.gemspec'), spec.to_ruby)
25
+ environment = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
26
+ environment.merge!('GEM_HOME' => directory,
27
+ 'GEM_PATH' => [directory, Gem.loaded_specs.fetch('json').base_dir, Gem.default_dir].uniq.join(File::PATH_SEPARATOR))
28
+ yield directory, environment
29
+ end
30
+ end
31
+
32
+ it 'loads the native calibrated version in each public entry despite a newer visible gem' do
33
+ with_newer_json do |_directory, environment|
34
+ [comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
35
+ program = <<~RUBY
36
+ visible = Gem::Specification.find_all_by_name('json').map { |spec| spec.version.to_s }
37
+ require #{entry.inspect}
38
+ puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('json').version.to_s,
39
+ parser: JSON::Ext::Parser.name, parsed: JSON.parse('{"value":42}'))
40
+ RUBY
41
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
42
+ expect(status).to be_success, errors
43
+ result = JSON.parse(output)
44
+ expect(result.fetch('visible')).to include('3.1.0')
45
+ expect(result).to include('loaded' => '2.21.2', 'parser' => 'JSON::Ext::Parser', 'parsed' => { 'value' => 42 })
46
+ end
47
+ end
48
+ end
49
+
50
+ it 'uses the same activation in the neutral worker serialization path' do
51
+ with_newer_json do |_directory, environment|
52
+ program = <<~RUBY
53
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
54
+ HaveAPI::Performance::Worker.define_method(:run) { { json_version: Gem.loaded_specs.fetch('json').version.to_s } }
55
+ exit HaveAPI::Performance::CLI.new(['worker']).execute
56
+ RUBY
57
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program, stdin_data: '{}')
58
+ expect(status).to be_success, errors
59
+ expect(JSON.parse(output)).to include('json_version' => '2.21.2')
60
+ end
61
+ end
62
+
63
+ it 'refuses an already activated incompatible version instead of weakening the environment' do
64
+ with_newer_json do |_directory, environment|
65
+ program = "gem 'json', '= 3.1.0'; require 'json'; require #{comparison.inspect}"
66
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
67
+ expect(status).not_to be_success
68
+ expect(errors).to include('json', '2.21.2', 'already activated json-3.1.0')
69
+ end
70
+ end
71
+
72
+ it 'reports the required version when the calibrated gem is missing' do
73
+ with_newer_json do |directory, environment|
74
+ environment['GEM_PATH'] = directory
75
+ program = "Gem.use_paths(#{directory.inspect}, [#{directory.inspect}]); require #{comparison.inspect}"
76
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
77
+ expect(status).not_to be_success
78
+ expect(errors).to include('json', '2.21.2', '3.1.0')
79
+ expect(errors).to match(/Could not find/)
80
+ end
81
+ end
82
+
83
+ it 'declares the calibrated JSON constraint in all three fresh development bundles' do
84
+ expected = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
85
+ .fetch('environment').fetch('weekly').fetch('gems').fetch('json').fetch('version')
86
+ expect(HaveAPI::Performance::JSON_VERSION).to eq(expected)
87
+ with_newer_json do |directory, environment|
88
+ HaveAPI::Performance::Release::JSON_GEMFILES.each do |path|
89
+ definition = Bundler::Dsl.evaluate(File.join(root, path), nil, {})
90
+ expect(definition.dependencies.select { |dependency| dependency.name == 'json' }.map { |dependency| dependency.requirement.to_s }).to eq(["= #{expected}"])
91
+ end
92
+ # Resolve only this installed constraint in an owned lockless fixture.
93
+ # No repository lock or external dependency is resolved or installed.
94
+ file = File.join(directory, 'Gemfile')
95
+ File.write(file, "source 'https://rubygems.org'\ngem 'json', '= #{expected}'\n")
96
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'lock', '--local', '--gemfile', file)
97
+ expect(status).to be_success, errors
98
+ expect(File.read("#{file}.lock")).to include("json (#{expected})", "json (= #{expected})")
99
+ expect(File.read("#{file}.lock")).not_to include('json (3.1.0)')
100
+ end
101
+ end
102
+
103
+ it 'canonicalizes only the exact known JSON preparation declaration and binds raw replay evidence' do
104
+ release = HaveAPI::Performance::Release
105
+ old = release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] }
106
+ pinned = old.transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
107
+ receipt = release.json_preparation(pinned)
108
+ expect(receipt.fetch('normalized_contents')).to eq(old)
109
+ expect(receipt.fetch('raw_sha256')).not_to eq(receipt.fetch('normalized_sha256'))
110
+ expect(receipt.fetch('declarations').values).to all(eq('exact-development-pin'))
111
+ unversioned = old.transform_values { |text| "#{text} gem 'json'\n" }
112
+ expect(release.json_preparation(unversioned).fetch('normalized_contents')).to eq(old)
113
+ ['3.1.0', '>= 2.21.2'].each do |requirement|
114
+ changed = pinned.transform_values { |text| text.sub('= 2.21.2', requirement) }
115
+ expect { release.json_preparation(changed) }.to raise_error(HaveAPI::Performance::ContractError)
116
+ end
117
+ changed = pinned.merge('Gemfile' => "#{pinned.fetch('Gemfile')}# unrelated comment\ngem 'other'\n")
118
+ expect(release.json_preparation(changed).fetch('normalized_contents')).not_to eq(old)
119
+ inputs = { 'json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256') }
120
+ source = { 'files_sha256' => receipt.fetch('raw_sha256') }
121
+ expect { release.validate_json_preparation(inputs, source) }.not_to raise_error
122
+ %w[raw_sha256 normalized_sha256 declarations].each do |field|
123
+ forged = Marshal.load(Marshal.dump(inputs))
124
+ forged['json_preparation'][field]['Gemfile'] = 'forged'
125
+ expect { release.validate_json_preparation(forged, source) }.to raise_error(HaveAPI::Performance::ContractError)
126
+ end
127
+ end
128
+
129
+ it 'replays the exact preparation delta while rejecting other content and raw source changes' do
130
+ release = HaveAPI::Performance::Release
131
+ %w[exact other-content raw-source].each do |control|
132
+ samples, budgets = PerformanceSamples.fixture
133
+ samples.fetch('observations').each_value do |observation|
134
+ next unless observation.fetch('source').fetch('sha') == samples.fetch('candidate_sha')
135
+
136
+ contents = observation.fetch('inputs').fetch('json_preparation').fetch('contents')
137
+ .transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
138
+ contents['Gemfile'] << "# other content\n" if control == 'other-content'
139
+ receipt = release.json_preparation(contents)
140
+ observation['inputs'].merge!('json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256'))
141
+ samples['sources']['candidate']['files_sha256'].merge!(receipt.fetch('raw_sha256')) unless control == 'raw-source'
142
+ end
143
+ comparator = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budgets), budget_sha256: samples.fetch('budget_sha256'))
144
+ if control == 'exact'
145
+ expect(comparator.compare.fetch('status')).to eq('pass')
146
+ else
147
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
148
+ end
149
+ end
150
+ end
151
+ end