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