haveapi 0.30.0 → 0.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +730 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +579 -0
- data/doc/client-examples.md +233 -24
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_clients.rb +158 -35
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/server.rb +5 -0
- 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/dynamic_js_spec.rb +1 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +202 -32
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/documentation/client_examples_spec.rb +10 -0
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +342 -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 +279 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +431 -0
- data/spec/performance/runner_spec.rb +476 -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 +26 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative '../../../../utils/performance_ci'
|
|
6
|
+
|
|
7
|
+
RSpec.describe HaveAPI::PerformanceCI do
|
|
8
|
+
let(:candidate) { sha('c') }
|
|
9
|
+
let(:default) { sha('d') }
|
|
10
|
+
let(:base) { sha('b') }
|
|
11
|
+
let(:event) { { 'repository' => { 'default_branch' => 'master' } } }
|
|
12
|
+
let(:repository) { instance_double(described_class::Repository) }
|
|
13
|
+
|
|
14
|
+
def sha(character)
|
|
15
|
+
character * 40
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolver
|
|
19
|
+
described_class::Resolver.new(repository)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
before do
|
|
23
|
+
allow(repository).to receive(:default_sha).with('master').and_return(default)
|
|
24
|
+
allow(repository).to receive(:commit!) do |value|
|
|
25
|
+
unless value.is_a?(String) && described_class::SHA.match?(value) && value != '0' * 40
|
|
26
|
+
raise described_class::Error, 'invalid commit'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
allow(repository).to receive_messages(ancestor?: true, paths: ['servers/ruby/lib/haveapi/server.rb'], release_lineage: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve(name, data, **options)
|
|
35
|
+
resolver.resolve(event_name: name, event: data, event_sha: candidate, **options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'selects the PR event head instead of its synthetic merge commit' do
|
|
39
|
+
event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
|
|
40
|
+
allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
|
|
41
|
+
|
|
42
|
+
result = resolver.resolve(event_name: 'pull_request', event:, event_sha: sha('e'))
|
|
43
|
+
expect(result).to include(candidate_sha: candidate, baseline_sha: base, profile: 'quick', relevant: true)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'compares an entire default-branch push batch against event before' do
|
|
47
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
|
|
48
|
+
allow(repository).to receive(:merge_base)
|
|
49
|
+
|
|
50
|
+
expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate)
|
|
51
|
+
expect(repository).not_to have_received(:merge_base)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[false, true].each do |new_branch|
|
|
55
|
+
it "uses the whole feature merge-base for a #{new_branch ? 'new' : 'subsequent'} push" do
|
|
56
|
+
event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
|
|
57
|
+
'before' => new_branch ? '0' * 40 : sha('a'), 'created' => new_branch)
|
|
58
|
+
allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
|
|
59
|
+
|
|
60
|
+
expect(resolve('push', event, default_sha: default)).to include(baseline_sha: base, captured_default_sha: default)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'rejects initial default-branch pushes without an available predecessor' do
|
|
65
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
|
|
66
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context 'with the verified maintenance lineage' do
|
|
70
|
+
def release
|
|
71
|
+
sha('e')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def lineage
|
|
75
|
+
{ 'candidate_sha' => candidate, 'mapped_sha' => HaveAPI::Performance::Release::MAPPED }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
before do
|
|
79
|
+
allow(repository).to receive(:release_lineage).with(candidate).and_return(lineage)
|
|
80
|
+
allow(repository).to receive(:default_sha).with('haveapi-0.30').and_return(release)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'compares the complete maintenance push batch using event before' do
|
|
84
|
+
event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
|
|
85
|
+
expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
|
|
86
|
+
release_lineage: lineage, reason: 'maintenance event before/after batch')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[false, true].each do |created|
|
|
90
|
+
it "captures the release head for a #{created ? 'new' : 'subsequent'} release feature" do
|
|
91
|
+
event.merge!('ref' => 'refs/heads/feature', 'before' => created ? '0' * 40 : sha('a'), 'after' => candidate)
|
|
92
|
+
allow(repository).to receive(:merge_base).with(candidate, release).and_return(base)
|
|
93
|
+
expect(resolve('push', event)).to include(baseline_sha: base, captured_release_sha: release,
|
|
94
|
+
captured_default_sha: default, release_lineage: lineage)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'fails if the captured release reference is unavailable' do
|
|
99
|
+
event.merge!('ref' => 'refs/heads/feature', 'after' => candidate)
|
|
100
|
+
allow(repository).to receive(:default_sha).with('haveapi-0.30').and_raise(described_class::Error, 'missing release ref')
|
|
101
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error, /missing release ref/)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'accepts only the original weekly reference through the proven mapping' do
|
|
105
|
+
event['inputs'] = { 'profile' => 'weekly', 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE }
|
|
106
|
+
allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
|
|
107
|
+
expect(resolve('workflow_dispatch', event)).to include(baseline_sha: described_class::REFERENCE, release_lineage: lineage)
|
|
108
|
+
event['inputs']['profile'] = 'quick'
|
|
109
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
110
|
+
event['inputs']['profile'] = 'weekly'
|
|
111
|
+
allow(repository).to receive(:release_lineage).with(candidate).and_return(nil)
|
|
112
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'does not accept an unrelated explicit quick baseline' do
|
|
116
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
117
|
+
allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
|
|
118
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'rejects unproven maintenance branch history' do
|
|
123
|
+
event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
|
|
124
|
+
expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'rejects deleted pushes and tag events' do
|
|
128
|
+
expect { resolve('push', event.merge('deleted' => true)) }.to raise_error(described_class::Error, /Deleted/)
|
|
129
|
+
expect { resolve('push', event.merge('ref' => 'refs/tags/v1')) }.to raise_error(described_class::Error, /branch/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'requires a real ancestor and never substitutes a same-head baseline' do
|
|
133
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
134
|
+
allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
|
|
135
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
|
|
136
|
+
event['inputs']['baseline_sha'] = candidate
|
|
137
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /differ/)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'uses the exact scheduled event SHA and preserves fixed reference ancestry' do
|
|
141
|
+
expect(resolve('schedule', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
|
|
142
|
+
expect(repository).to have_received(:ancestor?).with(described_class::REFERENCE, candidate)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'defaults manual runs to captured default and fixed reference' do
|
|
146
|
+
expect(resolve('workflow_dispatch', event)).to include(candidate_sha: default, baseline_sha: described_class::REFERENCE)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'accepts complete explicit manual SHA pairs and weekly profile' do
|
|
150
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE, 'profile' => 'weekly' }
|
|
151
|
+
expect(resolve('workflow_dispatch', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
|
|
152
|
+
event['inputs']['baseline_sha'] = base
|
|
153
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /fixed/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'rejects partial manual pairs, invalid profiles and missing event data' do
|
|
157
|
+
event['inputs'] = { 'candidate_sha' => candidate }
|
|
158
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error)
|
|
159
|
+
event['inputs'] = { 'profile' => 'unbounded' }
|
|
160
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /profile/)
|
|
161
|
+
expect { resolve('pull_request', {}) }.to raise_error(described_class::Error, /Incomplete/)
|
|
162
|
+
expect { resolve('pull_request_target', event.merge('inputs' => {})) }.to raise_error(described_class::Error, /Unsupported/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'does not hide unavailable commits behind a no-path success' do
|
|
166
|
+
event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
|
|
167
|
+
allow(repository).to receive(:commit!).with(base).and_raise(described_class::Error, 'unavailable')
|
|
168
|
+
expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, 'unavailable')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'skips only irrelevant valid push/PR changes and still forces scheduled/manual work' do
|
|
172
|
+
allow(repository).to receive(:paths).and_return(['clients/php/README.md'])
|
|
173
|
+
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
|
|
174
|
+
expect(resolve('push', event)).to include(relevant: false)
|
|
175
|
+
expect(resolve('schedule', event)).to include(relevant: true)
|
|
176
|
+
expect(resolve('workflow_dispatch', event)).to include(relevant: true)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'detects configuration, dependencies, harness and workflow changes' do
|
|
180
|
+
%w[flake.lock Gemfile Makefile .ruby-version servers/ruby/benchmarks/endpoints.rb
|
|
181
|
+
servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
|
|
182
|
+
doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
|
|
183
|
+
expect(described_class::RELEVANT).to match(path)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'only accepts successful job completion in the aggregate gate' do
|
|
188
|
+
expect(described_class.aggregate!('success', 'compared')).to be(true)
|
|
189
|
+
expect(described_class.aggregate!('success', 'no-work')).to be(true)
|
|
190
|
+
%w[failure cancelled skipped unknown].each do |result|
|
|
191
|
+
expect { described_class.aggregate!(result, 'compared') }.to raise_error(described_class::Error)
|
|
192
|
+
end
|
|
193
|
+
[nil, '', 'unknown'].each do |outcome|
|
|
194
|
+
expect { described_class.aggregate!('success', outcome) }.to raise_error(described_class::Error)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
|
|
199
|
+
path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
|
|
200
|
+
script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
|
|
201
|
+
[['success', 'compared', true], ['success', 'no-work', true], ['success', '', false],
|
|
202
|
+
['failure', 'compared', false], ['cancelled', 'compared', false], ['skipped', 'no-work', false]].each do |result, outcome, accepted|
|
|
203
|
+
_output, _error, status = Open3.capture3({ 'PERFORMANCE_RESULT' => result, 'PERFORMANCE_OUTCOME' => outcome },
|
|
204
|
+
'bash', '-e', '-c', script)
|
|
205
|
+
expect(status.success?).to eq(accepted), "#{result}/#{outcome}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
[0, 1, 3].each do |code|
|
|
210
|
+
it "preserves actual runner exit #{code} with a matching report" do
|
|
211
|
+
Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
|
|
212
|
+
checkout = File.join(directory, 'checkout')
|
|
213
|
+
FileUtils.mkdir_p(File.join(checkout, 'servers/ruby/benchmarks'))
|
|
214
|
+
script = <<~RUBY
|
|
215
|
+
require 'json'
|
|
216
|
+
require 'fileutils'
|
|
217
|
+
output = ARGV.fetch(ARGV.index('--output') + 1)
|
|
218
|
+
FileUtils.mkdir_p(output)
|
|
219
|
+
File.write(File.join(output, 'report.json'), JSON.generate(
|
|
220
|
+
schema_version: #{HaveAPI::Performance::SAMPLE_SCHEMA}, status: '#{{ 0 => 'pass', 1 => 'regression', 2 => 'inconclusive', 3 => 'error' }.fetch(code)}',
|
|
221
|
+
baseline_sha: '#{base}', candidate_sha: '#{candidate}'
|
|
222
|
+
))
|
|
223
|
+
exit #{code}
|
|
224
|
+
RUBY
|
|
225
|
+
File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
|
|
226
|
+
allow(repository).to receive(:root).and_return(checkout)
|
|
227
|
+
allow(repository).to receive(:git).with('rev-parse', 'HEAD').and_return(candidate)
|
|
228
|
+
allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
|
|
229
|
+
selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
|
|
230
|
+
'baseline_sha' => base, 'candidate_sha' => candidate }
|
|
231
|
+
campaign = described_class::Campaign.new(repository:, selection:,
|
|
232
|
+
evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
|
|
233
|
+
allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
|
|
234
|
+
allow(campaign).to receive(:export).with(:candidate, candidate).and_return(directory)
|
|
235
|
+
allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
|
|
236
|
+
expect(campaign.run).to eq(code)
|
|
237
|
+
expect(File.read(File.join(directory, 'evidence/runner.exit')).strip).to eq(code.to_s)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'returns error exit 3 from the actual resolver CLI on incomplete input' do
|
|
243
|
+
Dir.mktmpdir('haveapi-event-spec-') do |directory|
|
|
244
|
+
file = File.join(directory, 'event.json')
|
|
245
|
+
File.write(file, '{}')
|
|
246
|
+
script = File.expand_path('../../../../utils/performance_ci.rb', __dir__)
|
|
247
|
+
_output, error, status = Open3.capture3(RbConfig.ruby, script, 'resolve', '--event', file,
|
|
248
|
+
'--event-name', 'pull_request', '--event-sha', candidate,
|
|
249
|
+
'--repository', File.expand_path('../../../..', __dir__),
|
|
250
|
+
'--output', File.join(directory, 'selection.json'))
|
|
251
|
+
expect(status.exitstatus).to eq(3)
|
|
252
|
+
expect(error).to include('Incomplete event')
|
|
253
|
+
expect(File).not_to exist(File.join(directory, 'selection.json'))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it 'keeps the hosted workflow always-created, read-only and bounded' do
|
|
258
|
+
path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
|
|
259
|
+
workflow = YAML.load_file(path)
|
|
260
|
+
triggers = workflow.fetch('on') { workflow.fetch(true) }
|
|
261
|
+
expect(triggers.keys).to contain_exactly('push', 'pull_request', 'schedule', 'workflow_dispatch')
|
|
262
|
+
expect(triggers.fetch('push')).not_to have_key('paths')
|
|
263
|
+
expect(triggers.fetch('pull_request')).to be_nil
|
|
264
|
+
expect(triggers.fetch('schedule')).to eq([{ 'cron' => '0 3 * * 0' }])
|
|
265
|
+
expect(workflow.fetch('permissions')).to eq('contents' => 'read')
|
|
266
|
+
job = workflow.fetch('jobs').fetch('performance')
|
|
267
|
+
expect(job.fetch('runs-on')).to eq('ubuntu-24.04')
|
|
268
|
+
expect(job.fetch('timeout-minutes')).to include('45', '15')
|
|
269
|
+
gate = workflow.fetch('jobs').fetch('gate')
|
|
270
|
+
expect(gate).to include('name' => 'Performance gate', 'needs' => 'performance', 'if' => 'always()')
|
|
271
|
+
expect(gate.fetch('steps').first.fetch('run')).to include('test "$PERFORMANCE_RESULT" = success')
|
|
272
|
+
upload = job.fetch('steps').find { |step| step.fetch('uses', '').start_with?('actions/upload-artifact@') }
|
|
273
|
+
expect(upload).to include('if' => 'always()')
|
|
274
|
+
expect(upload.fetch('with')).to include('retention-days' => 30, 'if-no-files-found' => 'error')
|
|
275
|
+
job.fetch('steps').select { |step| step.fetch('uses', '').start_with?('actions/checkout@') }.each do |step|
|
|
276
|
+
expect(step.fetch('with')).to include('persist-credentials' => false, 'fetch-depth' => 0)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -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
|