haveapi 0.30.1 → 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 +110 -3
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/version.rb +1 -1
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -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 +23 -3
|
@@ -25,6 +25,212 @@ describe HaveAPI::ClientExample::Context do
|
|
|
25
25
|
expect(child).to be_frozen
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
describe '#with' do
|
|
29
|
+
let(:fields) do
|
|
30
|
+
{
|
|
31
|
+
version: 1, host: 'api.test', base_url: 'https://api.test/mount/',
|
|
32
|
+
version_url: 'https://api.test/mount/v1/',
|
|
33
|
+
description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
|
|
34
|
+
resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
|
|
35
|
+
action_name: :show, action: { 'method' => 'GET' },
|
|
36
|
+
sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
let(:parent) { described_class.new(**fields) }
|
|
40
|
+
let(:replacements) do
|
|
41
|
+
{
|
|
42
|
+
version: 2, host: 'other.test', base_url: 'https://other.test/api/',
|
|
43
|
+
version_url: 'https://other.test/api/v2/',
|
|
44
|
+
description: { 'resources' => { 'other' => { 'label' => 'Other' } } },
|
|
45
|
+
resource_path: [:other, 3], resource: { 'label' => 'Other' },
|
|
46
|
+
action_name: :update, action: { 'method' => 'POST' },
|
|
47
|
+
sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
|
|
48
|
+
profile: Object.new
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'returns a distinct frozen context sharing every unchanged field' do
|
|
53
|
+
child = parent.with
|
|
54
|
+
|
|
55
|
+
expect(child).not_to equal(parent)
|
|
56
|
+
expect(child).to be_frozen
|
|
57
|
+
described_class::FIELDS.each do |field|
|
|
58
|
+
expect(child.public_send(field)).to equal(parent.public_send(field))
|
|
59
|
+
end
|
|
60
|
+
expect(child.description['resources']['widget']).to equal(parent.description['resources']['widget'])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
HaveAPI::ClientExample::Context::FIELDS.each do |field|
|
|
64
|
+
it "normalizes an explicit #{field} replacement like the constructor and shares other fields" do
|
|
65
|
+
changed = { field => replacements.fetch(field) }
|
|
66
|
+
child = parent.with(**changed)
|
|
67
|
+
constructor = described_class.new(**fields, **changed)
|
|
68
|
+
|
|
69
|
+
expect(child).to be_frozen
|
|
70
|
+
expect(child.public_send(field)).to eq(constructor.public_send(field))
|
|
71
|
+
(described_class::FIELDS - [field]).each do |unchanged|
|
|
72
|
+
expect(child.public_send(unchanged)).to equal(parent.public_send(unchanged))
|
|
73
|
+
end
|
|
74
|
+
expect(parent.public_send(field)).to eq(described_class.new(**fields).public_send(field))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'copies supplied nested containers and strings even when their outer hash is frozen' do
|
|
79
|
+
key = ['mutable key']
|
|
80
|
+
source = { key => [{ 'value' => 'safe' }] }.freeze
|
|
81
|
+
child = parent.with(description: source, resource: source, action: source, sample: source)
|
|
82
|
+
|
|
83
|
+
%i[description resource action sample].each do |field|
|
|
84
|
+
owned = child.public_send(field)
|
|
85
|
+
expect(owned).not_to equal(source)
|
|
86
|
+
expect(owned).to be_frozen
|
|
87
|
+
expect(owned.keys.first).not_to equal(key)
|
|
88
|
+
expect(owned.keys.first).to be_frozen
|
|
89
|
+
expect(owned.keys.first.first).to be_frozen
|
|
90
|
+
expect(owned.values.first).to be_frozen
|
|
91
|
+
expect(owned.values.first.first).to be_frozen
|
|
92
|
+
expect(owned.values.first.first.keys.first).to be_frozen
|
|
93
|
+
expect(owned.values.first.first['value']).to be_frozen
|
|
94
|
+
end
|
|
95
|
+
expect(key).not_to be_frozen
|
|
96
|
+
expect(key.first).not_to be_frozen
|
|
97
|
+
expect(source.values.first).not_to be_frozen
|
|
98
|
+
expect(source.values.first.first['value']).not_to be_frozen
|
|
99
|
+
key.first.replace('changed key')
|
|
100
|
+
source.values.first.first['value'].replace('changed')
|
|
101
|
+
%i[description resource action sample].each do |field|
|
|
102
|
+
expect(child.public_send(field)).to eq(['mutable key'] => [{ 'value' => 'safe' }])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'detaches explicitly supplied strings and converts resource path elements without retaining them' do
|
|
107
|
+
text = +'value'
|
|
108
|
+
path = [text, :widget, 7]
|
|
109
|
+
child = parent.with(version: text, host: text, base_url: text, version_url: text,
|
|
110
|
+
resource_path: path, action_name: text)
|
|
111
|
+
|
|
112
|
+
%i[version host base_url version_url action_name].each do |field|
|
|
113
|
+
expect(child.public_send(field)).to eq('value')
|
|
114
|
+
expect(child.public_send(field)).not_to equal(text)
|
|
115
|
+
expect(child.public_send(field)).to be_frozen
|
|
116
|
+
end
|
|
117
|
+
expect(child.resource_path).to eq(%w[value widget 7])
|
|
118
|
+
expect(child.resource_path).to be_frozen
|
|
119
|
+
expect(child.resource_path).not_to equal(path)
|
|
120
|
+
expect(child.resource_path).to all(be_frozen)
|
|
121
|
+
expect(path).not_to be_frozen
|
|
122
|
+
expect(text).not_to be_frozen
|
|
123
|
+
text.replace('changed')
|
|
124
|
+
path << 'extra'
|
|
125
|
+
expect(child.version).to eq('value')
|
|
126
|
+
expect(child.resource_path).to eq(%w[value widget 7])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'copies explicitly supplied owned values again instead of treating frozen data as trusted' do
|
|
130
|
+
child = parent.with(description: parent.description, resource_path: parent.resource_path,
|
|
131
|
+
sample: parent.sample)
|
|
132
|
+
|
|
133
|
+
%i[description resource_path sample].each do |field|
|
|
134
|
+
expect(child.public_send(field)).to eq(parent.public_send(field))
|
|
135
|
+
expect(child.public_send(field)).not_to equal(parent.public_send(field))
|
|
136
|
+
end
|
|
137
|
+
expect(child.description['resources']).not_to equal(parent.description['resources'])
|
|
138
|
+
expect(child.sample['request']['values']).not_to equal(parent.sample['request']['values'])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'keeps parent, sibling and chained derivations detached from their supplied inputs' do
|
|
142
|
+
data = { request: { name: 'safe' } }
|
|
143
|
+
child = parent.with(sample: data)
|
|
144
|
+
sibling = parent.with(sample: data)
|
|
145
|
+
grandchild = child.with(action_name: :update)
|
|
146
|
+
fields[:description]['resources']['widget']['label'].replace('changed source')
|
|
147
|
+
data[:request][:name].replace('changed sample')
|
|
148
|
+
|
|
149
|
+
expect(parent.description['resources']['widget']['label']).to eq('Widget')
|
|
150
|
+
expect(child.description).to equal(parent.description)
|
|
151
|
+
expect(sibling.description).to equal(parent.description)
|
|
152
|
+
expect(grandchild.description).to equal(parent.description)
|
|
153
|
+
expect(parent.sample).to eq('request' => { 'values' => [0, false, '', nil] })
|
|
154
|
+
expect(child.sample).to eq(request: { name: 'safe' })
|
|
155
|
+
expect(sibling.sample).to eq(child.sample)
|
|
156
|
+
expect(sibling.sample).not_to equal(child.sample)
|
|
157
|
+
expect(grandchild.sample).to equal(child.sample)
|
|
158
|
+
expect(child.action_name).to eq('show')
|
|
159
|
+
expect(grandchild.action_name).to eq('update')
|
|
160
|
+
expect { child.sample[:request][:name].replace('forbidden') }.to raise_error(FrozenError)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'distinguishes omitted fields from explicit nil replacements' do
|
|
164
|
+
child = parent.with(description: nil, resource: nil, action_name: nil, action: nil, sample: nil, profile: nil)
|
|
165
|
+
|
|
166
|
+
%i[description resource action_name action sample profile].each do |field|
|
|
167
|
+
expect(child.public_send(field)).to be_nil
|
|
168
|
+
expect(parent.public_send(field)).not_to be_nil
|
|
169
|
+
end
|
|
170
|
+
expect(child.version).to equal(parent.version)
|
|
171
|
+
expect(parent.with(version: nil, host: nil, base_url: nil, version_url: nil))
|
|
172
|
+
.to have_attributes(version: '', host: '', base_url: '', version_url: '')
|
|
173
|
+
expect(context.with.action_name).to be_nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'shares arbitrary profiles and non-container leaves without freezing them' do
|
|
177
|
+
leaf = Object.new
|
|
178
|
+
profile = fields.fetch(:profile)
|
|
179
|
+
child = parent.with(description: { custom: leaf }, profile: leaf)
|
|
180
|
+
|
|
181
|
+
expect(parent.profile).to equal(profile)
|
|
182
|
+
expect(parent.with.profile).to equal(profile)
|
|
183
|
+
expect(profile).not_to be_frozen
|
|
184
|
+
expect(child.profile).to equal(leaf)
|
|
185
|
+
expect(child.description[:custom]).to equal(leaf)
|
|
186
|
+
expect(leaf).not_to be_frozen
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'continues rejecting invalid resource paths' do
|
|
190
|
+
expect { parent.with(resource_path: nil) }.to raise_error(NoMethodError)
|
|
191
|
+
expect { described_class.new(**fields, resource_path: nil) }.to raise_error(NoMethodError)
|
|
192
|
+
expect(parent.resource_path).to eq(%w[widget 2])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'rejects unknown keywords before normalizing any supplied values' do
|
|
196
|
+
value = Object.new
|
|
197
|
+
allow(value).to receive(:to_s).and_call_original
|
|
198
|
+
|
|
199
|
+
expect { parent.with(version: value, unknown: true) }.to raise_error(ArgumentError, 'unknown keyword: :unknown')
|
|
200
|
+
expect { parent.with(unknown: true, other: false) }
|
|
201
|
+
.to raise_error(ArgumentError, 'unknown keywords: :unknown, :other')
|
|
202
|
+
expect { parent.with('version' => '2') }.to raise_error(ArgumentError, 'unknown keyword: "version"')
|
|
203
|
+
expect(value).not_to have_received(:to_s)
|
|
204
|
+
expect(parent).not_to respond_to(:unknown)
|
|
205
|
+
expect(parent.version).to eq('1')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'keeps subclass constructors, extra keywords and constructor effects on every derivation' do
|
|
209
|
+
subclass = Class.new(described_class) do
|
|
210
|
+
attr_reader :marker, :initializations
|
|
211
|
+
|
|
212
|
+
def initialize(marker: :default, initializations: 0, **fields)
|
|
213
|
+
@marker = marker
|
|
214
|
+
@initializations = initializations + 1
|
|
215
|
+
super(**fields)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
original = subclass.new(**fields, marker: :original)
|
|
219
|
+
child = original.with(marker: :child, initializations: original.initializations)
|
|
220
|
+
defaulted = original.with
|
|
221
|
+
|
|
222
|
+
expect(child).to be_a(subclass)
|
|
223
|
+
expect(child).to be_frozen
|
|
224
|
+
expect(child.marker).to eq(:child)
|
|
225
|
+
expect(child.initializations).to eq(2)
|
|
226
|
+
expect(defaulted.marker).to eq(:default)
|
|
227
|
+
expect(original.marker).to eq(:original)
|
|
228
|
+
expect(original.initializations).to eq(1)
|
|
229
|
+
expect(child.description).to eq(original.description)
|
|
230
|
+
expect(child.description).not_to equal(original.description)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
28
234
|
it 'preserves legacy initialization constructors and wraps plain source' do
|
|
29
235
|
renderer = Class.new(HaveAPI::ClientExample) do
|
|
30
236
|
def initialize(host, base_url, version)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'base64'
|
|
3
|
+
require_relative '../../test_support/documentation_performance_fixture'
|
|
4
|
+
|
|
5
|
+
describe HaveAPI::Server, '#mount' do
|
|
6
|
+
let(:server) do
|
|
7
|
+
HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
|
|
8
|
+
end
|
|
9
|
+
let(:contexts) { [] }
|
|
10
|
+
let(:help_inputs) { [] }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
HaveAPI::ClientExample.clients.each do |client|
|
|
14
|
+
allow(client).to receive(:render).and_wrap_original do |original, kind, context:, **arguments|
|
|
15
|
+
contexts << context
|
|
16
|
+
original.call(kind, context:, **arguments)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
|
|
20
|
+
original.call(*arguments).tap do |help|
|
|
21
|
+
help_inputs << [help, Marshal.dump(help)]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def request_as(user)
|
|
27
|
+
contexts.clear
|
|
28
|
+
Rack::MockRequest.new(server.app).get(
|
|
29
|
+
'/benchmark/api/v1/', 'HTTP_HOST' => 'example.test',
|
|
30
|
+
'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expect_shared_request(response)
|
|
35
|
+
expect(response.status).to eq(200)
|
|
36
|
+
expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
|
|
37
|
+
'init-javascript-callback', 'init-javascript-async',
|
|
38
|
+
'init-typescript-callback', 'init-typescript-async',
|
|
39
|
+
'init-python-sync', 'init-python-async')
|
|
40
|
+
expect(contexts.length).to be > 20
|
|
41
|
+
description = contexts.first.description
|
|
42
|
+
contexts.each { |context| expect(context.description).to equal(description) }
|
|
43
|
+
expect(description).to be_frozen
|
|
44
|
+
description
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'shares one description across real panels and isolates sequential admin/member/admin requests' do
|
|
48
|
+
first_admin = request_as('admin')
|
|
49
|
+
admin_description = expect_shared_request(first_admin)
|
|
50
|
+
privileged_markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE
|
|
51
|
+
ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
|
|
52
|
+
privileged_markers.each { |marker| expect(first_admin.body).to include(marker) }
|
|
53
|
+
|
|
54
|
+
member = request_as('member')
|
|
55
|
+
member_description = expect_shared_request(member)
|
|
56
|
+
expect(member_description).not_to equal(admin_description)
|
|
57
|
+
privileged_markers.each { |marker| expect(member.body).not_to include(marker) }
|
|
58
|
+
expect(JSON.generate(contexts.map { |context| [context.description, context.action, context.sample] }))
|
|
59
|
+
.not_to match(/ADMIN_ONLY_|admin_field|admin_only/)
|
|
60
|
+
expect(member_description[:resources]['resource0'][:actions]).not_to have_key('admin_only')
|
|
61
|
+
selected = contexts.find { |context| context.action_name == 'action0' }
|
|
62
|
+
expect(selected.action[:input][:parameters]).not_to have_key(:admin_field)
|
|
63
|
+
expect(selected.sample[:request]).to include(enabled: false, count: 0, nullable: nil, value0: '')
|
|
64
|
+
|
|
65
|
+
last_admin = request_as('admin')
|
|
66
|
+
restored_description = expect_shared_request(last_admin)
|
|
67
|
+
expect(restored_description).not_to equal(admin_description)
|
|
68
|
+
expect(restored_description).not_to equal(member_description)
|
|
69
|
+
expect(restored_description).to eq(admin_description)
|
|
70
|
+
privileged_markers.each { |marker| expect(last_admin.body).to include(marker) }
|
|
71
|
+
|
|
72
|
+
help_inputs.each do |help, original|
|
|
73
|
+
expect(Marshal.dump(help)).to eq(original)
|
|
74
|
+
expect(help).not_to be_frozen
|
|
75
|
+
expect(help[:resources]).not_to be_frozen
|
|
76
|
+
expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require_relative 'support/samples'
|
|
5
|
+
|
|
6
|
+
describe HaveAPI::Performance::Comparator do
|
|
7
|
+
def compare_cli(samples, budgets)
|
|
8
|
+
Dir.mktmpdir('haveapi-comparator-') do |directory|
|
|
9
|
+
input = File.join(directory, 'samples.json')
|
|
10
|
+
policy = File.join(directory, 'budgets.json')
|
|
11
|
+
output = File.join(directory, 'result')
|
|
12
|
+
File.write(input, JSON.generate(samples))
|
|
13
|
+
File.write(policy, budgets)
|
|
14
|
+
_stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
|
|
15
|
+
'compare', '--input', input, '--budgets', policy, '--output', output)
|
|
16
|
+
[status.exitstatus, JSON.parse(File.read(File.join(output, 'report.json')))]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def replace_raw_body(sample, body)
|
|
21
|
+
output = sample['outputs'].first
|
|
22
|
+
raw = sample['bodies'].delete(output['body_sha256'])
|
|
23
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
24
|
+
sample['bodies'][digest] = raw.merge('body' => body)
|
|
25
|
+
output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rebind_source(samples, role, sha)
|
|
29
|
+
samples["#{role}_sha"] = sha
|
|
30
|
+
samples['sources'][role]['sha'] = sha
|
|
31
|
+
samples['observations'].each_value do |observation|
|
|
32
|
+
observation['source']['sha'] = sha if observation['source']['root'] == samples['sources'][role]['root']
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'passes controlled AA traces through the actual CLI' do
|
|
37
|
+
samples, budgets = PerformanceSamples.fixture
|
|
38
|
+
code, report = compare_cli(samples, budgets)
|
|
39
|
+
expect(code).to eq(0)
|
|
40
|
+
expect(report['status']).to eq('pass')
|
|
41
|
+
expect(report['cases'].first['timing'].keys).to eq(%w[wall_seconds cpu_seconds])
|
|
42
|
+
expect(report['schema_version']).to eq(2)
|
|
43
|
+
expect(report['cases'].first['correctness']).to include('status' => 'pass', 'operations_per_sample' => 1)
|
|
44
|
+
expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'rejects unsupported sample formats without converting historical evidence' do
|
|
48
|
+
samples, budgets = PerformanceSamples.fixture
|
|
49
|
+
samples['schema_version'] = 1
|
|
50
|
+
code, report = compare_cli(samples, budgets)
|
|
51
|
+
expect(code).to eq(3)
|
|
52
|
+
expect(report.dig('error', 'message')).to include('Unsupported samples schema; expected 2')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'blocks an agreed meaningful slowdown for either clock independently' do
|
|
56
|
+
%w[wall_seconds cpu_seconds].each do |metric|
|
|
57
|
+
samples, budgets = PerformanceSamples.fixture
|
|
58
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = 3.0 }
|
|
59
|
+
code, report = compare_cli(samples, budgets)
|
|
60
|
+
expect(code).to eq(1)
|
|
61
|
+
expect(report['cases'].first['timing'][metric]['regression_agreement']).to eq(5)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'retains noisy inconclusive diagnostics without failing readiness' do
|
|
66
|
+
samples, budgets = PerformanceSamples.fixture
|
|
67
|
+
samples['cases'].first['self_pairs'].first['candidate']['wall_seconds'] = 6.0
|
|
68
|
+
code, report = compare_cli(samples, budgets)
|
|
69
|
+
expect(code).to eq(0)
|
|
70
|
+
expect(report['status']).to eq('inconclusive')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'requires four of five quick agreements and six of seven weekly agreements' do
|
|
74
|
+
%w[quick weekly].each do |profile|
|
|
75
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
76
|
+
agreement = HaveAPI::Performance::PROFILES.fetch(profile).fetch(:agreement)
|
|
77
|
+
samples['cases'].first['pairs'].first(agreement - 1).each { |pair| pair['candidate']['wall_seconds'] = 3.0 }
|
|
78
|
+
code, report = compare_cli(samples, budgets)
|
|
79
|
+
expect(code).to eq(0)
|
|
80
|
+
expect(report['status']).to eq('inconclusive')
|
|
81
|
+
samples['cases'].first['pairs'][agreement - 1]['candidate']['wall_seconds'] = 3.0
|
|
82
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'uses the stricter weekly budget on the same fifteen percent increase' do
|
|
87
|
+
%w[quick weekly].each do |profile|
|
|
88
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
89
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 2.30 }
|
|
90
|
+
expect(compare_cli(samples, budgets).first).to eq(profile == 'quick' ? 0 : 1)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'detects a cheap-request twenty-five percent regression using the batch floor' do
|
|
95
|
+
samples, budgets = PerformanceSamples.fixture
|
|
96
|
+
item = samples['cases'].first
|
|
97
|
+
item['iterations'] = 25_000
|
|
98
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
99
|
+
pair.each_value do |sample|
|
|
100
|
+
next unless sample.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
sample['operations'] = 25_000
|
|
103
|
+
sample['outputs'].first['count'] = 25_000
|
|
104
|
+
sample['counters']['operations'] = 25_000
|
|
105
|
+
sample['wall_seconds'] = sample['cpu_seconds'] = 0.00004
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
item['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 0.00005 }
|
|
109
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'validates distinct examples and rejects malformed compact counts, identities and counter summaries' do
|
|
113
|
+
samples, json = PerformanceSamples.fixture
|
|
114
|
+
budget = JSON.parse(json)
|
|
115
|
+
spec = samples['cases'].first
|
|
116
|
+
sample = Marshal.load(Marshal.dump(spec['pairs'].first['candidate']))
|
|
117
|
+
sample['operations'] = 4
|
|
118
|
+
sample['outputs'].first['count'] = 4
|
|
119
|
+
sample['counters']['operations'] = 4
|
|
120
|
+
comparator = described_class.new(samples, budget, budget_sha256: samples['budget_sha256'])
|
|
121
|
+
body = sample['bodies'].values.first['body']
|
|
122
|
+
allow(HaveAPI::Performance::Identity).to receive(:parse).and_call_original
|
|
123
|
+
comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
|
|
124
|
+
expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).once
|
|
125
|
+
%w[http_status envelope_status body_bytes body_sha256 canonical_sha256 comparison counter missing zero negative float total counter_count duplicate unreferenced].each do |fault|
|
|
126
|
+
broken = Marshal.load(Marshal.dump(sample))
|
|
127
|
+
output = broken['outputs'].last
|
|
128
|
+
case fault
|
|
129
|
+
when 'http_status' then output[fault] = 403
|
|
130
|
+
when 'envelope_status' then output[fault] = false
|
|
131
|
+
when 'body_bytes' then output[fault] += 1
|
|
132
|
+
when 'comparison' then output[fault] = { 'kind' => 'own-webui-href', 'own_version' => '0.30.2' }
|
|
133
|
+
when 'counter' then broken['counters']['record']['authorizations'] = 0
|
|
134
|
+
when 'missing' then broken['counters'].delete('record')
|
|
135
|
+
when 'zero' then output['count'] = 0
|
|
136
|
+
when 'negative' then output['count'] = -1
|
|
137
|
+
when 'float' then output['count'] = 4.0
|
|
138
|
+
when 'total' then output['count'] = 3
|
|
139
|
+
when 'counter_count' then broken['counters']['operations'] = 4.0
|
|
140
|
+
when 'duplicate'
|
|
141
|
+
output['count'] = 2
|
|
142
|
+
broken['outputs'] << output.dup
|
|
143
|
+
when 'unreferenced'
|
|
144
|
+
other_body = '{"status":true}'
|
|
145
|
+
broken['bodies'][Digest::SHA256.hexdigest(other_body)] = { 'body' => other_body, 'canonical_body' => JSON.parse(other_body) }
|
|
146
|
+
else output[fault] = '0' * 64
|
|
147
|
+
end
|
|
148
|
+
expect { comparator.send(:validate_sample, broken, 4, budget['profiles']['quick'].first, spec) }.to raise_error(StandardError)
|
|
149
|
+
end
|
|
150
|
+
# A second sample must independently prove its retained body again.
|
|
151
|
+
comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
|
|
152
|
+
expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).at_least(:twice)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'rejects raw/canonical disagreement and duplicate JSON on a reused body and rejects counts above the shared cap' do
|
|
156
|
+
%w[canonical duplicate cap].each do |fault|
|
|
157
|
+
samples, json = PerformanceSamples.fixture
|
|
158
|
+
spec = samples['cases'].first
|
|
159
|
+
sample = spec['pairs'].first['candidate']
|
|
160
|
+
if fault == 'canonical'
|
|
161
|
+
sample['bodies'].values.first['canonical_body']['response']['zero'] = 1
|
|
162
|
+
elsif fault == 'duplicate'
|
|
163
|
+
body = sample['bodies'].values.first['body'].sub('"status":true', '"status":false,"status":true')
|
|
164
|
+
replace_raw_body(sample, body)
|
|
165
|
+
else
|
|
166
|
+
spec['iterations'] = 100_001
|
|
167
|
+
end
|
|
168
|
+
comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
|
|
169
|
+
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'compares response occurrence totals across revisions without retaining request order' do
|
|
174
|
+
samples, json = PerformanceSamples.fixture
|
|
175
|
+
spec = samples['cases'].first
|
|
176
|
+
spec['iterations'] = 3
|
|
177
|
+
(spec['self_pairs'] + spec['pairs']).each do |pair|
|
|
178
|
+
%w[baseline candidate].each do |role|
|
|
179
|
+
sample = pair[role]
|
|
180
|
+
output = sample['outputs'].first
|
|
181
|
+
body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":1')
|
|
182
|
+
canonical = JSON.parse(body)
|
|
183
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
184
|
+
sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
|
|
185
|
+
sample['operations'] = 3
|
|
186
|
+
sample['outputs'] << output.merge('body_sha256' => digest, 'body_bytes' => body.bytesize,
|
|
187
|
+
'canonical_sha256' => HaveAPI::Performance::Identity.digest(canonical), 'count' => 2)
|
|
188
|
+
sample['counters']['operations'] = 3
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
|
|
192
|
+
expect(comparator.compare['status']).to eq('pass')
|
|
193
|
+
spec['pairs'].last['candidate']['outputs'].reverse!
|
|
194
|
+
expect(comparator.compare['status']).to eq('pass')
|
|
195
|
+
spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
|
|
196
|
+
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'rejects retained runaway request batches even if both sources are equally slow' do
|
|
200
|
+
samples, budgets = PerformanceSamples.fixture
|
|
201
|
+
item = samples['cases'].first
|
|
202
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
203
|
+
%w[baseline candidate].each { |role| pair[role]['wall_seconds'] = 100.0 }
|
|
204
|
+
end
|
|
205
|
+
code, report = compare_cli(samples, budgets)
|
|
206
|
+
expect(code).to eq(3)
|
|
207
|
+
expect(report.dig('error', 'message')).to include('Request batch exceeds 30.0s')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'rejects raw integer/float drift even when forged raw metadata is consistent' do
|
|
211
|
+
[[0, 0.0], [0.0, 0]].each do |original, changed|
|
|
212
|
+
samples, budgets = PerformanceSamples.fixture(zero: original)
|
|
213
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
214
|
+
body = JSON.parse(target['bodies'].values.first['body'])
|
|
215
|
+
body['response']['zero'] = changed
|
|
216
|
+
replace_raw_body(target, JSON.generate(body))
|
|
217
|
+
code, report = compare_cli(samples, budgets)
|
|
218
|
+
expect(code).to eq(3)
|
|
219
|
+
expect(report.dig('error', 'message')).to include('Raw/canonical output values differ')
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'allows raw map-order changes through the actual CLI' do
|
|
224
|
+
samples, budgets = PerformanceSamples.fixture
|
|
225
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
226
|
+
body = JSON.parse(target['bodies'].values.first['body'])
|
|
227
|
+
body['response'] = body['response'].to_a.reverse.to_h
|
|
228
|
+
replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
|
|
229
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'rejects identical source SHAs even with consistently rebound provenance' do
|
|
233
|
+
samples, budgets = PerformanceSamples.fixture
|
|
234
|
+
rebind_source(samples, 'candidate', samples['baseline_sha'])
|
|
235
|
+
code, report = compare_cli(samples, budgets)
|
|
236
|
+
expect(code).to eq(3)
|
|
237
|
+
expect(report.dig('error', 'message')).to include('Baseline and candidate must differ')
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'requires the fixed weekly baseline and accepts valid fixed-reference evidence' do
|
|
241
|
+
samples, budgets = PerformanceSamples.fixture(profile: 'weekly')
|
|
242
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
243
|
+
rebind_source(samples, 'baseline', 'a' * 40)
|
|
244
|
+
code, report = compare_cli(samples, budgets)
|
|
245
|
+
expect(code).to eq(3)
|
|
246
|
+
expect(report.dig('error', 'message')).to include('Weekly baseline must be the fixed reference')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'requires both the percentage and absolute timing thresholds' do
|
|
250
|
+
samples, budgets = PerformanceSamples.fixture
|
|
251
|
+
samples['cases'].first['pairs'].each do |pair|
|
|
252
|
+
pair['baseline']['wall_seconds'] = 0.0001
|
|
253
|
+
pair['candidate']['wall_seconds'] = 0.00013
|
|
254
|
+
end
|
|
255
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'blocks allocation growth and the independent fixed reference ceiling' do
|
|
259
|
+
samples, budgets = PerformanceSamples.fixture
|
|
260
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 140 }
|
|
261
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
262
|
+
samples['cases'].first['pairs'].each do |pair|
|
|
263
|
+
pair['baseline']['allocated_objects'] = 180
|
|
264
|
+
pair['candidate']['allocated_objects'] = 180
|
|
265
|
+
end
|
|
266
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'requires full Rust cases and their relative allocation and authorization contracts' do
|
|
270
|
+
%w[missing allocations authorization].each do |fault|
|
|
271
|
+
samples, budgets = PerformanceSamples.fixture
|
|
272
|
+
case fault
|
|
273
|
+
when 'missing'
|
|
274
|
+
samples['inventory'].pop
|
|
275
|
+
samples['cases'].pop
|
|
276
|
+
when 'allocations'
|
|
277
|
+
samples['cases'].last['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 200 }
|
|
278
|
+
else
|
|
279
|
+
samples['cases'].last['pairs'].first['candidate']['counters']['record']['authorizations'] -= 1
|
|
280
|
+
end
|
|
281
|
+
expect(compare_cli(samples, budgets).first).to eq(fault == 'allocations' ? 1 : 3)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it 'blocks uncalibrated reference budgets' do
|
|
286
|
+
samples, json = PerformanceSamples.fixture
|
|
287
|
+
budgets = JSON.parse(json)
|
|
288
|
+
budgets.delete('calibration')
|
|
289
|
+
json = JSON.generate(budgets)
|
|
290
|
+
samples['budget_sha256'] = Digest::SHA256.hexdigest(json)
|
|
291
|
+
expect(compare_cli(samples, json).first).to eq(3)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'blocks missing cases, operations, dependency graphs and source provenance' do
|
|
295
|
+
%w[cases outputs inputs sources].each do |missing|
|
|
296
|
+
samples, budgets = PerformanceSamples.fixture
|
|
297
|
+
case missing
|
|
298
|
+
when 'cases' then samples['cases'].clear
|
|
299
|
+
when 'outputs' then samples['cases'].first['pairs'].first['candidate']['outputs'].clear
|
|
300
|
+
else samples.delete(missing)
|
|
301
|
+
end
|
|
302
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it 'rejects missing or changed workload order and runtime policy' do
|
|
307
|
+
%w[workload_order worker_order runtime_policy].each do |fault|
|
|
308
|
+
samples, budgets = PerformanceSamples.fixture
|
|
309
|
+
case fault
|
|
310
|
+
when 'workload_order' then samples['cases'].first['pairs'][1]['workload_order'] = 'forward'
|
|
311
|
+
when 'worker_order' then samples['observations']['candidate-reverse']['workload_order'] = 'forward'
|
|
312
|
+
else samples['observations']['baseline']['runtime_policy']['timezone'] = 'local'
|
|
313
|
+
end
|
|
314
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'blocks extra queries, lost authorizations, wrong output and changed dependency graphs' do
|
|
319
|
+
%w[queries authorizations output environment].each do |fault|
|
|
320
|
+
samples, budgets = PerformanceSamples.fixture
|
|
321
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
322
|
+
case fault
|
|
323
|
+
when 'queries' then target['counters']['record']['queries'] = 1
|
|
324
|
+
when 'authorizations' then target['counters']['record']['authorizations'] = 0
|
|
325
|
+
when 'output' then target['outputs'].first['canonical_sha256'] = '0' * 64
|
|
326
|
+
else samples['observations']['candidate']['environment'] = {}
|
|
327
|
+
end
|
|
328
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it 'preserves scalar types and array order while ignoring map order only' do
|
|
333
|
+
identity = HaveAPI::Performance::Identity
|
|
334
|
+
expect(identity.digest(a: 0, b: [false, '', nil])).to eq(identity.digest('b' => [false, '', nil], 'a' => 0))
|
|
335
|
+
expect(identity.digest(a: 0)).not_to eq(identity.digest(a: 0.0))
|
|
336
|
+
expect(identity.digest(a: [false, nil])).not_to eq(identity.digest(a: [nil, false]))
|
|
337
|
+
expect { identity.digest(a: 0, 'a' => 1) }.to raise_error(HaveAPI::Performance::ContractError)
|
|
338
|
+
['{"status":false,"status":true}', '{"a":[{"b":1,"b":2}]}'].each do |json|
|
|
339
|
+
expect { identity.parse(json) }.to raise_error(HaveAPI::Performance::ContractError, /Duplicate/)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|