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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +786 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +678 -0
- data/benchmarks/shell.nix +21 -0
- data/doc/client-examples.md +179 -26
- data/doc/performance.md +310 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
- data/lib/haveapi/documentation_clients.rb +49 -6
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/client_examples/profile_mount_spec.rb +45 -0
- data/spec/client_examples/ts_generated_client_spec.rb +53 -0
- data/spec/client_examples/typescript_profiles_spec.rb +113 -0
- data/spec/documentation/client_examples_spec.rb +2 -2
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +472 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +407 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +470 -0
- data/spec/performance/runner_spec.rb +610 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- metadata +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
|