haveapi 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,470 @@
1
+ require 'spec_helper'
2
+ require_relative '../../../../utils/performance_ci'
3
+ require_relative '../../benchmarks/endpoints'
4
+ require_relative '../../../../clients/ruby/lib/haveapi/client/version'
5
+ require_relative 'support/samples'
6
+ require_relative '../../test_support/documentation_performance_fixture'
7
+
8
+ describe HaveAPI::Performance::Release do
9
+ let(:root) { File.expand_path('../../../..', __dir__) }
10
+ let(:repository) { HaveAPI::PerformanceCI::Repository.new(git_fixture_root) }
11
+ let(:head) { git_fixture.fetch(:candidate) }
12
+ let(:proof) { repository.release_lineage(head) }
13
+ let(:error) { HaveAPI::Performance::ContractError }
14
+
15
+ # Component CI intentionally has shallow source history and no root bundle.
16
+ # Own the proof graph instead of depending on the checkout's maintenance refs.
17
+ around do |example|
18
+ Dir.mktmpdir('haveapi-release-history-') do |directory|
19
+ example.metadata[:git_fixture_root] = directory
20
+ example.metadata[:git_fixture] = build_history(directory)
21
+ example.run
22
+ end
23
+ end
24
+
25
+ before do
26
+ stub_const('HaveAPI::Performance::REFERENCE', git_fixture.fetch(:reference))
27
+ stub_const('HaveAPI::Performance::Release::MAPPED', git_fixture.fetch(:mapped))
28
+ stub_const('HaveAPI::Performance::Release::METADATA', git_fixture.fetch(:metadata))
29
+ stub_const('HaveAPI::Performance::Release::TREES', git_fixture.fetch(:trees))
30
+ stub_const('HaveAPI::Performance::Release::DELTA', git_fixture.fetch(:delta_sha256))
31
+ end
32
+
33
+ def git_fixture_root
34
+ RSpec.current_example.metadata.fetch(:git_fixture_root)
35
+ end
36
+
37
+ def git_fixture
38
+ RSpec.current_example.metadata.fetch(:git_fixture)
39
+ end
40
+
41
+ def fixture_git(directory, *arguments, input: '')
42
+ env = { 'GIT_AUTHOR_NAME' => 'Fixture', 'GIT_AUTHOR_EMAIL' => 'fixture@example.test',
43
+ 'GIT_COMMITTER_NAME' => 'Fixture', 'GIT_COMMITTER_EMAIL' => 'fixture@example.test',
44
+ 'GIT_AUTHOR_DATE' => '2000-01-01T00:00:00Z', 'GIT_COMMITTER_DATE' => '2000-01-01T00:00:00Z' }
45
+ output, errors, status = Open3.capture3(env, 'git', '-C', directory, *arguments, stdin_data: input)
46
+ raise errors unless status.success?
47
+
48
+ output
49
+ end
50
+
51
+ def fixture_commit(directory, files, parent: nil)
52
+ entries = files.sort.map do |path, body|
53
+ blob = fixture_git(directory, 'hash-object', '-w', '--stdin', input: body).strip
54
+ "100644 blob #{blob}\t#{path}\n"
55
+ end
56
+ tree = fixture_git(directory, 'mktree', input: entries.join).strip
57
+ fixture_git(directory, 'commit-tree', tree, *[('-p' if parent), parent].compact, input: "fixture\n").strip
58
+ end
59
+
60
+ def build_history(directory)
61
+ fixture_git(directory, 'init', '--quiet', '--initial-branch=master')
62
+ original = { 'VERSION' => "0.30.0\n", 'CHANGELOG.md' => "original\n" }
63
+ base = fixture_commit(directory, original)
64
+ reference = fixture_commit(directory, original.merge('engine.rb' => "source\n"), parent: base)
65
+ published = original.merge('VERSION' => "0.30.1\n", 'CHANGELOG.md' => "published 0.30.1\n\noriginal\n")
66
+ metadata = fixture_commit(directory, published, parent: base)
67
+ mapped = fixture_commit(directory, published.merge('engine.rb' => "source\n"), parent: metadata)
68
+ candidate = fixture_commit(directory, published.merge('engine.rb' => "source\n", 'tool.rb' => "candidate\n"), parent: mapped)
69
+ fixture_git(directory, 'update-ref', 'refs/heads/master', reference)
70
+ fixture_git(directory, 'update-ref', 'refs/heads/maintenance', candidate)
71
+ delta = fixture_git(directory, 'diff', '--binary', '--full-index', reference, mapped)
72
+ { reference:, metadata:, mapped:, candidate:,
73
+ trees: [reference, mapped, "#{metadata}^", metadata].map { |sha| fixture_git(directory, 'rev-parse', "#{sha}^{tree}").strip },
74
+ delta_sha256: Digest::SHA256.hexdigest(delta) }
75
+ end
76
+
77
+ def contents(version, profile: 'quick')
78
+ original = described_class.paths(profile).to_h { |path| [path, File.read(File.join(root, path))] }
79
+ own = original.fetch('VERSION').strip
80
+ original.to_h do |path, value|
81
+ value = "#{version}\n" if path == 'VERSION'
82
+ value = value.sub("VERSION = '#{own}'", "VERSION = '#{version}'") if described_class::VERSIONS.has_key?(path)
83
+ value = value.sub("'haveapi-client', '~> #{own}'", "'haveapi-client', '~> #{version}'") if path.end_with?('.gemspec')
84
+ [path, value]
85
+ end
86
+ end
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
+
98
+ it 'verifies the complete published binary delta and all four exact trees in an owned history' do
99
+ expect(proof).to include('reference_sha' => HaveAPI::Performance::REFERENCE, 'mapped_sha' => described_class::MAPPED,
100
+ 'metadata_sha' => described_class::METADATA, 'trees' => described_class::TREES)
101
+ expect(Digest::SHA256.hexdigest(proof.fetch('reference_delta'))).to eq(described_class::DELTA)
102
+ expect(proof.fetch('reference_delta')).to eq(proof.fetch('metadata_delta'))
103
+ expect(repository.ancestor?(HaveAPI::Performance::REFERENCE, head)).to be(false)
104
+ expect(repository.ancestor?(described_class::MAPPED, head)).to be(true)
105
+ end
106
+
107
+ it 'keeps the proof canonical with abbreviated IDs and changed Git display defaults' do
108
+ [4, 7, 12].each do |abbreviation|
109
+ fixture_git(git_fixture_root, 'config', 'core.abbrev', abbreviation.to_s)
110
+ fixture_git(git_fixture_root, 'config', 'diff.context', '0')
111
+ fixture_git(git_fixture_root, 'config', 'diff.noprefix', 'true')
112
+ fixture_git(git_fixture_root, 'config', 'diff.suppressBlankEmpty', 'true')
113
+ fixture_git(git_fixture_root, 'config', 'diff.external', 'false')
114
+ expect(repository.release_lineage(head)).to eq(proof)
115
+ end
116
+ end
117
+
118
+ it 'accepts ordinary master ancestry in a shallow clone without maintenance objects' do
119
+ Dir.mktmpdir('haveapi-shallow-master-') do |directory|
120
+ fixture_git(directory, 'clone', '--quiet', '--depth=1', '--single-branch', '--branch=master',
121
+ "file://#{git_fixture_root}", '.')
122
+ shallow = HaveAPI::PerformanceCI::Repository.new(directory)
123
+ expect { shallow.git('cat-file', '-e', described_class::MAPPED) }.to raise_error(HaveAPI::PerformanceCI::Error)
124
+ expect(shallow.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
125
+ end
126
+ end
127
+
128
+ it 'rejects modified metadata, reference trees and candidate binding during replay' do
129
+ %w[metadata_delta reference_delta trees candidate_sha].each do |field|
130
+ changed = Marshal.load(Marshal.dump(proof))
131
+ changed[field] = field == 'trees' ? ['f' * 40] * 4 : 'wrong'
132
+ expect { described_class.validate_lineage(changed, head) }.to raise_error(error, /lineage/)
133
+ end
134
+ end
135
+
136
+ it 'does not infer the mapping for nonancestor candidates and fails on missing history' do
137
+ expect(repository.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
138
+ expect { repository.release_lineage('f' * 40) }.to raise_error(HaveAPI::PerformanceCI::Error, /ancestry/)
139
+ git = ->(*) { raise HaveAPI::PerformanceCI::Error, 'missing metadata object' }
140
+ expect do
141
+ described_class.lineage(git:, ancestor: ->(left, _right) { left == described_class::MAPPED }, candidate: head)
142
+ end.to raise_error(HaveAPI::PerformanceCI::Error, /missing metadata/)
143
+ end
144
+
145
+ it 'rejects a wrong full tree delta on the live proof path' do
146
+ git = lambda do |*arguments|
147
+ result = repository.git(*arguments)
148
+ arguments.include?('diff') ? "#{result}\n# changed" : result
149
+ end
150
+ expect do
151
+ described_class.lineage(git:, ancestor: repository.method(:ancestor?), candidate: head)
152
+ end.to raise_error(error, /lineage/)
153
+ end
154
+
155
+ it 'uses the same proof in the direct runner and limits nonliteral ancestry to weekly' do
156
+ %w[weekly quick missing wrong-delta].each do |mode|
157
+ profile = mode == 'quick' ? 'quick' : 'weekly'
158
+ cli = HaveAPI::Performance::CLI.new(['run', '--baseline-sha', HaveAPI::Performance::REFERENCE,
159
+ '--candidate-sha', head, '--profile', profile,
160
+ '--baseline-root', root, '--candidate-root', root,
161
+ '--budgets', 'unused', '--output', 'unused'])
162
+ cli.send(:parse)
163
+ allow(cli).to receive(:verify_runtime)
164
+ allow(HaveAPI::Performance).to receive(:gemspec)
165
+ allow(cli).to receive(:verify_source) { |role| { root: "/controlled/#{role}", sha: head } }
166
+ allow(cli).to receive(:git) { |*arguments| repository.git(*arguments) }
167
+ allow(cli).to receive(:ancestor?) { |left, right| repository.ancestor?(left, right) }
168
+ if mode == 'missing'
169
+ allow(cli).to receive(:ancestor?).and_return(false)
170
+ elsif mode == 'wrong-delta'
171
+ allow(cli).to receive(:git).with(*described_class::DIFF, HaveAPI::Performance::REFERENCE, described_class::MAPPED).and_return('wrong')
172
+ end
173
+ allow(cli).to receive(:child).and_raise(error, 'unexpected worker')
174
+ if mode == 'weekly'
175
+ allow(cli).to receive(:child).with('baseline', {}, 'pilot').and_raise(error, 'verified pilot boundary')
176
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, 'verified pilot boundary')
177
+ expect(cli).to have_received(:child).with('baseline', {}, 'pilot')
178
+ else
179
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, /ancestor|lineage/)
180
+ expect(cli).not_to have_received(:child)
181
+ end
182
+ end
183
+ end
184
+
185
+ it 'normalizes only synchronized own patch identities, preserving every external input' do
186
+ %w[quick weekly].each do |profile|
187
+ old = described_class.capture(contents('0.30.1', profile:))
188
+ new = described_class.capture(contents('0.30.2', profile:))
189
+ expect(old.fetch('raw_sha256')).not_to eq(new.fetch('raw_sha256'))
190
+ expect(old.fetch('normalized_sha256')).to eq(new.fetch('normalized_sha256'))
191
+ expect(new.fetch('own_version')).to eq('0.30.2')
192
+ %w[flake.lock flake.nix .ruby-version Gemfile].each do |path|
193
+ changed = contents('0.30.2', profile:)
194
+ changed[path] += "\n# arbitrary input change\n"
195
+ expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
196
+ end
197
+ %w[required_ruby_version summary activesupport].each do |field|
198
+ changed = contents('0.30.2', profile:)
199
+ changed['servers/ruby/haveapi.gemspec'] = changed.fetch('servers/ruby/haveapi.gemspec').sub(field, "changed_#{field}")
200
+ expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
201
+ end
202
+ end
203
+ end
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
+
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/)
219
+ [
220
+ ['VERSION', "0.31.2\n"],
221
+ ['VERSION', " 0.30.2\n"],
222
+ ['servers/ruby/lib/haveapi/version.rb', "module HaveAPI\n VERSION = '0.30.3'\nend\n"],
223
+ ['servers/ruby/haveapi.gemspec', "spec.name = 'unrelated'\n"],
224
+ ['servers/ruby/haveapi.gemspec', "spec.name = 'haveapi'\nspec.add_dependency 'haveapi-client', '~> 0.30.3'\n"]
225
+ ].each do |path, value|
226
+ expect { described_class.capture(contents('0.30.2').merge(path => value)) }.to raise_error(error)
227
+ end
228
+ end
229
+
230
+ it 'retains actual worker input receipts and rejects mismatched loaded local components' do
231
+ # Provide owned lock bytes at the input boundary. The production worker still
232
+ # refuses a missing root lock; component-only CI need not install that bundle.
233
+ Dir.mktmpdir('haveapi-release-locks-') do |harness|
234
+ FileUtils.mkdir_p(File.join(harness, 'servers/ruby'))
235
+ File.write(File.join(harness, 'Gemfile.lock'), "controlled tooling lock\n")
236
+ File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
237
+ FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
238
+ File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
239
+ stub_const('HaveAPI::Performance::ROOT', harness)
240
+ ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
241
+ preparation = ordinary.fetch('json_preparation')
242
+ expect(preparation.fetch('declarations').values).to all(eq('exact-development-pin'))
243
+ expect(ordinary.fetch('manifests').slice(*described_class::JSON_GEMFILES)).to eq(preparation.fetch('normalized_sha256'))
244
+ expect do
245
+ described_class.validate_json_preparation(ordinary, 'files_sha256' => preparation.fetch('raw_sha256'))
246
+ end.not_to raise_error
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
264
+ Dir.mktmpdir('haveapi-release-inputs-') do |source|
265
+ contents('0.30.99').each do |path, value|
266
+ FileUtils.mkdir_p(File.dirname(File.join(source, path)))
267
+ File.write(File.join(source, path), value)
268
+ end
269
+ expect do
270
+ HaveAPI::Performance::Worker.inputs(source, 'quick', release: true)
271
+ end.to raise_error(error, /Wrong loaded local package version/)
272
+ end
273
+ end
274
+ end
275
+
276
+ def framework_footer(version)
277
+ %(<p class="text-right">Generated by <a href="https://github.com/vpsfreecz/haveapi">HaveAPI</a> v#{version}</p>)
278
+ end
279
+
280
+ def served_html
281
+ server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1)
282
+ response = Rack::MockRequest.new(server.app).get('http://example.test/benchmark/api/v1/',
283
+ 'HTTP_HOST' => 'example.test', 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64('admin:fixture-only')}")
284
+ expect(response.status).to eq(200)
285
+ response.body
286
+ end
287
+
288
+ def html_samples(served: nil)
289
+ samples, budget_json = PerformanceSamples.fixture
290
+ samples['release_lineage'] = proof.merge('candidate_sha' => samples.fetch('candidate_sha'))
291
+ normalized = nil
292
+ samples.fetch('sources').each do |role, source|
293
+ receipt = described_class.capture(contents(role == 'baseline' ? '0.30.1' : '0.30.2'))
294
+ source['files_sha256'].merge!(receipt.fetch('raw_sha256'))
295
+ inputs = { 'manifests' => receipt.fetch('normalized_sha256'), 'locks' => samples.fetch('inputs').fetch('locks'),
296
+ 'release_versions' => receipt }
297
+ normalized ||= described_class.graph(inputs)
298
+ samples.fetch('observations').each_value do |observation|
299
+ next unless observation.fetch('source').fetch('root') == source.fetch('root')
300
+
301
+ observation['inputs'] = inputs
302
+ end
303
+ end
304
+ samples['inputs'] = normalized
305
+ id = 'docs.html_resources'
306
+ samples['inventory'].first['id'] = id
307
+ item = samples.fetch('cases').first
308
+ item['id'] = id
309
+ (item.fetch('self_pairs') + item.fetch('pairs')).each do |pair|
310
+ %w[baseline candidate].each do |role|
311
+ sample = pair.fetch(role)
312
+ own = samples.fetch('observations').fetch(sample.fetch('observation')).fetch('inputs').fetch('release_versions').fetch('own_version')
313
+ body = if served
314
+ source_version = File.read(File.join(root, 'VERSION')).strip
315
+ served.gsub("https://webui.haveapi.org/v#{source_version}/#", "https://webui.haveapi.org/v#{own}/#")
316
+ .sub(framework_footer(source_version), framework_footer(own))
317
+ else
318
+ "<p>PUBLIC_SAMPLE_0 init-python-sync init-examples</p><a\n href=\"https://webui.haveapi.org/v#{own}/#http%3A%2F%2Fexample.test\">Connect</a>#{framework_footer(own)}"
319
+ end
320
+ digest = Digest::SHA256.hexdigest(body)
321
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
322
+ sample['outputs'] = [{ 'http_status' => 200, 'envelope_status' => nil, 'body_bytes' => body.bytesize, 'body_sha256' => digest, 'count' => sample.fetch('operations'),
323
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, own)),
324
+ 'comparison' => { 'kind' => 'own-webui-href', 'own_version' => own } }]
325
+ end
326
+ end
327
+ budgets = JSON.parse(budget_json)
328
+ budgets['profiles']['quick'].first['id'] = id
329
+ samples['budget_sha256'] = Digest::SHA256.hexdigest(JSON.generate(budgets))
330
+ [samples, budgets]
331
+ end
332
+
333
+ def compare(samples, budgets)
334
+ HaveAPI::Performance::Comparator.new(samples, budgets, budget_sha256: samples.fetch('budget_sha256')).compare
335
+ end
336
+
337
+ it 'replays distinct raw bodies and input hashes through the narrow normalization' do
338
+ samples, budgets = html_samples
339
+ expect(compare(samples, budgets).fetch('status')).to eq('pass')
340
+ pair = samples.fetch('cases').first.fetch('pairs').first
341
+ expect(pair['baseline']['outputs'].first['body_sha256']).not_to eq(pair['candidate']['outputs'].first['body_sha256'])
342
+ expect(pair['baseline']['outputs'].first['canonical_sha256']).to eq(pair['candidate']['outputs'].first['canonical_sha256'])
343
+ end
344
+
345
+ it 'accepts real served footer/version changes and rejects a forged digest through the replay CLI' do
346
+ samples, budgets = html_samples(served: served_html)
347
+ Dir.mktmpdir('haveapi-release-replay-') do |directory|
348
+ policy = File.join(directory, 'budget.json')
349
+ input = File.join(directory, 'samples.json')
350
+ File.write(policy, JSON.generate(budgets))
351
+ # The CLI runs unchanged, with the same owned mini-history policy as this
352
+ # example. No production defect switch or fixture-history dependency exists.
353
+ fixture = File.join(directory, 'history.json')
354
+ File.write(fixture, JSON.generate(git_fixture))
355
+ adapter = File.join(directory, 'replay.rb')
356
+ File.write(adapter, <<~RUBY)
357
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
358
+ history = JSON.parse(File.read(ARGV.shift))
359
+ performance = HaveAPI::Performance
360
+ performance.send(:remove_const, :REFERENCE)
361
+ performance.const_set(:REFERENCE, history.fetch('reference'))
362
+ { 'MAPPED' => 'mapped', 'METADATA' => 'metadata', 'TREES' => 'trees', 'DELTA' => 'delta_sha256' }.each do |name, key|
363
+ performance::Release.send(:remove_const, name)
364
+ performance::Release.const_set(name, history.fetch(key))
365
+ end
366
+ exit performance::CLI.new(ARGV).execute
367
+ RUBY
368
+ [false, true].each do |tampered|
369
+ samples['cases'].first['pairs'].first['candidate']['outputs'].first['canonical_sha256'] = 'f' * 64 if tampered
370
+ File.write(input, JSON.generate(samples))
371
+ _output, _errors, status = Open3.capture3(RbConfig.ruby, adapter, fixture, 'compare',
372
+ '--input', input, '--budgets', policy,
373
+ '--output', File.join(directory, tampered ? 'tampered' : 'valid'))
374
+ expect(status.exitstatus).to eq(tampered ? 3 : 0)
375
+ end
376
+ end
377
+ end
378
+
379
+ it 'normalizes the actual worker HTML outside timing and retains its raw bytes' do
380
+ own = '0.30.2'
381
+ body = "<a href=\"https://webui.haveapi.org/v#{own}/#api\">Connect</a>#{framework_footer(own)}"
382
+ scenario = HaveAPI::Performance::DocumentationScenario.allocate
383
+ allow(scenario).to receive(:reset!)
384
+ allow(scenario).to receive_messages(call: Rack::MockResponse.new(200, {}, body),
385
+ validate!: { http_status: 200, envelope_status: nil, body_bytes: body.bytesize,
386
+ body_sha256: Digest::SHA256.hexdigest(body), canonical_sha256: Digest::SHA256.hexdigest(body) },
387
+ counters: { operations: [{ queries: 0, authorizations: 0, executions: 0 }] })
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
397
+ end
398
+
399
+ it 'leaves lookalike attributes, other URLs, JSON and example code byte exact' do
400
+ [
401
+ '<a data-href="https://webui.haveapi.org/v0.30.2/#api">',
402
+ '<a href="https://other.test/v0.30.2/#api">',
403
+ '<a href="https://webui.haveapi.org.evil/v0.30.2/#api">',
404
+ '<a href="http://webui.haveapi.org/v0.30.2/#api">',
405
+ '<a href="https://webui.haveapi.org/v0.30.3/#api">',
406
+ '<a href="https://webui.haveapi.org/v0.30.2/other#api">',
407
+ '{"version":"0.30.2","url":"https://webui.haveapi.org/v0.30.2/#api"}',
408
+ '&lt;a href="https://webui.haveapi.org/v0.30.2/#api"&gt;',
409
+ '<code><a href="https://webui.haveapi.org/v0.30.2/#api"></a></code>',
410
+ '<PRE><a href="https://webui.haveapi.org/v0.30.2/#api"></a></PRE>',
411
+ '<script>let x = \'<a href="https://webui.haveapi.org/v0.30.2/#api">\';</script>',
412
+ '<!-- <a href="https://webui.haveapi.org/v0.30.2/#api"> -->'
413
+ ].each do |body|
414
+ expect(described_class.html(body + framework_footer('0.30.2'), '0.30.2')).to eq(body + framework_footer('0.30.PATCH'))
415
+ end
416
+ end
417
+
418
+ it 'requires one exact framework footer matching the verified own version' do
419
+ footer = framework_footer('0.30.2')
420
+ [footer.sub('Generated by', 'Produced by'), footer.sub('github.com', 'other.test'),
421
+ framework_footer('0.30.3'), footer + footer, '<p>No footer</p>',
422
+ "<code>#{footer}</code>", "<!-- #{footer} -->", "<div data-example='#{footer}'></div>"].each do |body|
423
+ expect { described_class.html(body, '0.30.2') }.to raise_error(error, /footer/)
424
+ end
425
+ end
426
+
427
+ it 'preserves footer-like examples, attributes and unrelated version strings' do
428
+ footer = framework_footer('0.30.2')
429
+ opaque = "<pre>#{footer}</pre><code>v0.30.2</code><!-- #{footer} --><div data-example='#{footer}'></div>"
430
+ expect(described_class.html(opaque + footer, '0.30.2')).to eq(opaque + framework_footer('0.30.PATCH'))
431
+ samples, budgets = html_samples(served: served_html)
432
+ sample = samples['cases'].first['pairs'].first['candidate']
433
+ body = "#{sample['bodies'].values.first['body']}<code>v0.30.2</code>"
434
+ digest = Digest::SHA256.hexdigest(body)
435
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
436
+ sample['outputs'].first.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
437
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
438
+ expect { compare(samples, budgets) }.to raise_error(error, /Response identity/)
439
+ end
440
+
441
+ it 'rejects forged raw/normalized hashes, output differences and unverified input contents on replay' do
442
+ %w[raw normalized marker graph raw_graph api auth panels].each do |fault|
443
+ samples, budgets = html_samples
444
+ sample = samples.fetch('cases').first.fetch('pairs').first.fetch('candidate')
445
+ observation = samples.fetch('observations').fetch(sample.fetch('observation'))
446
+ output = sample.fetch('outputs').first
447
+ case fault
448
+ when 'raw' then sample['bodies'].values.first['body'] += 'tampered'
449
+ when 'normalized' then output['canonical_sha256'] = 'f' * 64
450
+ when 'marker' then output['comparison']['own_version'] = '0.30.1'
451
+ when 'graph' then observation['inputs']['release_versions']['normalized_sha256']['Gemfile'] = 'f' * 64
452
+ when 'raw_graph'
453
+ receipt = described_class.capture(observation['inputs']['release_versions']['contents'].merge('Gemfile' => '# replaced'))
454
+ observation['inputs'].merge!('release_versions' => receipt, 'manifests' => receipt['normalized_sha256'])
455
+ else
456
+ raw = sample['bodies'].values.first['body']
457
+ body = case fault
458
+ when 'api' then "#{raw}<pre>{\"version\":\"0.30.2\"}</pre>"
459
+ when 'auth' then "#{raw}ADMIN_ONLY_REQUEST"
460
+ else raw.sub('init-examples', '')
461
+ end
462
+ digest = Digest::SHA256.hexdigest(body)
463
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
464
+ output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
465
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
466
+ end
467
+ expect { compare(samples, budgets) }.to raise_error(error), fault
468
+ end
469
+ end
470
+ end