haveapi 0.30.2 → 0.31.1

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.
@@ -8,6 +8,8 @@ module HaveAPI
8
8
  end
9
9
  require 'digest'
10
10
  require 'json'
11
+ require 'open3'
12
+ require 'rbconfig'
11
13
 
12
14
  module HaveAPI
13
15
  module Performance
@@ -18,6 +20,7 @@ module HaveAPI
18
20
  MAX_ITERATIONS = 100_000
19
21
  REQUEST_LIMIT_SECONDS = 30.0
20
22
  SETUP_LIMIT_SECONDS = 120.0
23
+ BENCHMARK_INPUTS = %w[servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix].freeze
21
24
 
22
25
  def self.complex_cases(profile)
23
26
  sizes = profile == 'weekly' ? [32] : [16]
@@ -30,6 +33,34 @@ module HaveAPI
30
33
  RUNTIME_POLICY = { 'rack_env' => 'test', 'locale' => 'C.UTF-8', 'timezone' => 'UTC', 'yjit' => false, 'rjit' => false }.freeze
31
34
  class ContractError < StandardError; end
32
35
 
36
+ def self.gemspec(root, path)
37
+ script = <<~RUBY
38
+ require 'json'
39
+ spec = Gem::Specification.load(File.basename(ARGV.fetch(0)))
40
+ abort 'Cannot load gemspec' unless spec
41
+ puts JSON.generate(name: spec.name, version: spec.version.to_s, ruby: spec.required_ruby_version.to_s,
42
+ dependencies: spec.dependencies.map { |d| [d.name, d.type.to_s, d.requirement.to_s] }.sort)
43
+ RUBY
44
+ environment = (ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]).to_h { |key| [key, nil] }
45
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', script, path, chdir: File.dirname(File.join(root, path)))
46
+ raise ContractError, "Cannot load source gemspec #{path}: #{errors.strip}" unless status.success?
47
+
48
+ specification = JSON.parse(output)
49
+ unless Gem::Requirement.new(specification.fetch('ruby')).satisfied_by?(Gem::Version.new(RUBY_VERSION))
50
+ raise ContractError, "Benchmark Ruby does not satisfy #{path}"
51
+ end
52
+
53
+ specification.fetch('dependencies').each do |name, type, requirement|
54
+ next unless type == 'runtime'
55
+ next if %w[haveapi haveapi-client haveapi-rust-client].include?(name)
56
+
57
+ unless Gem::Requirement.new(requirement.split(',').map(&:strip)).satisfied_by?(Gem::Specification.find_by_name(name).version)
58
+ raise ContractError, "Benchmark dependencies do not satisfy #{name} #{requirement}"
59
+ end
60
+ end
61
+ specification
62
+ end
63
+
33
64
  # One reviewed maintenance mapping, not a general cherry-pick certificate.
34
65
  # Both live entry points verify Git; replay verifies the retained raw delta.
35
66
  module Release
@@ -166,7 +197,10 @@ module HaveAPI
166
197
  receipt = inputs.fetch('release_versions')
167
198
  raise ContractError, 'Incomplete release input graph' unless receipt.fetch('contents').keys.sort == paths(profile).sort
168
199
  raise ContractError, 'Release input normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
169
- raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == receipt.fetch('normalized_sha256')
200
+
201
+ expected = receipt.fetch('normalized_sha256')
202
+ expected = expected.except('flake.nix', 'flake.lock') if inputs.has_key?('benchmark')
203
+ raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == expected
170
204
 
171
205
  receipt.fetch('raw_sha256').each do |path, digest|
172
206
  raise ContractError, 'Release input escaped verified source' unless source.fetch('files_sha256').fetch(path) == digest
@@ -175,7 +209,7 @@ module HaveAPI
175
209
  end
176
210
 
177
211
  def self.graph(inputs)
178
- inputs.except('release_versions', 'json_preparation')
212
+ inputs.except('release_versions', 'source_versions', 'json_preparation', 'contributor_flakes')
179
213
  end
180
214
 
181
215
  def self.html(body, own)
@@ -204,6 +238,103 @@ module HaveAPI
204
238
  end
205
239
  end
206
240
 
241
+ # Ordinary source ancestry needs no historical maintenance certificate.
242
+ # Only synchronized package metadata is omitted from input equality.
243
+ module SourceVersions
244
+ PLACEHOLDER = 'OWN.VERSION'.freeze
245
+ COMPONENTS = Release::VERSIONS
246
+ SPECS = COMPONENTS.to_h { |path, name| ["#{path.split('/lib/').first}/#{name}.gemspec", name] }.freeze
247
+ PATHS = (['VERSION'] + COMPONENTS.keys + SPECS.keys).freeze
248
+
249
+ def self.capture(contents)
250
+ raise ContractError, 'Incomplete own source metadata' unless contents.keys.sort == PATHS.sort
251
+
252
+ own = contents.fetch('VERSION').delete_suffix("\n")
253
+ unless own.match?(/\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z/) && contents['VERSION'] == "#{own}\n"
254
+ raise ContractError, 'Invalid own source VERSION'
255
+ end
256
+
257
+ normalized = contents.merge('VERSION' => "#{PLACEHOLDER}\n")
258
+ COMPONENTS.each do |path, name|
259
+ text = contents.fetch(path)
260
+ declaration = /^(\s*VERSION = )'#{Regexp.escape(own)}'(?=\.freeze$)/
261
+ unless text.scan(/^\s*VERSION\s*=/).length == 1 && text.scan(declaration).length == 1
262
+ raise ContractError, "Unsynchronized own version: #{name}"
263
+ end
264
+
265
+ normalized[path] = text.sub(declaration, "\\1'#{PLACEHOLDER}'")
266
+ end
267
+ SPECS.each do |path, name|
268
+ text = contents.fetch(path)
269
+ constant = { 'haveapi' => 'HaveAPI', 'haveapi-client' => 'HaveAPI::Client', 'haveapi-rust-client' => 'HaveAPI::RustClient' }.fetch(name)
270
+ unless text.scan(/\.name\s*=/).length == 1 && text.scan(/\.name\s*= '#{Regexp.escape(name)}'$/).length == 1 &&
271
+ text.scan(/\.version\s*=/).length == 1 && text.scan(/\.version\s*= #{Regexp.escape(constant)}::VERSION$/).length == 1
272
+ raise ContractError, 'Mismatched own package identity'
273
+ end
274
+
275
+ dependencies = text.scan(/\.add_(?:runtime_|development_)?dependency\s+['"]haveapi-client['"]/)
276
+ requirement = /(?<=\.add_dependency )'haveapi-client', '~> #{Regexp.escape(own)}'(?=$)/
277
+ if name == 'haveapi-client'
278
+ raise ContractError, 'Unexpected own client dependency' unless dependencies.empty?
279
+ else
280
+ unless dependencies.length == 1 && text.scan(requirement).length == 1
281
+ raise ContractError, 'Unsynchronized own client constraint'
282
+ end
283
+
284
+ normalized[path] = text.sub(requirement, "'haveapi-client', '~> #{PLACEHOLDER}'")
285
+ end
286
+ end
287
+ { 'contents' => contents, 'own_version' => own,
288
+ 'raw_sha256' => contents.transform_values { |text| Digest::SHA256.hexdigest(text) },
289
+ 'normalized_sha256' => normalized.transform_values { |text| Digest::SHA256.hexdigest(text) } }
290
+ end
291
+
292
+ def self.gemspec(root, path, receipt)
293
+ spec = Performance.gemspec(root, path)
294
+ own = receipt.fetch('own_version')
295
+ unless spec.fetch('name') == SPECS.fetch(path) && spec.fetch('version') == own
296
+ raise ContractError, 'Evaluated own package identity mismatch'
297
+ end
298
+
299
+ dependencies = spec.fetch('dependencies').select { |name, _type, _requirement| name == 'haveapi-client' }
300
+ expected = SPECS.fetch(path) == 'haveapi-client' ? [] : [['haveapi-client', 'runtime', "~> #{own}"]]
301
+ raise ContractError, 'Evaluated own client constraint mismatch' unless dependencies == expected
302
+
303
+ spec.merge('version' => PLACEHOLDER, 'dependencies' => spec.fetch('dependencies').map do |name, type, requirement|
304
+ [name, type, name == 'haveapi-client' ? "~> #{PLACEHOLDER}" : requirement]
305
+ end)
306
+ end
307
+
308
+ def self.validate_inputs(inputs, source)
309
+ receipt = inputs.fetch('source_versions')
310
+ raise ContractError, 'Own source metadata normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
311
+
312
+ receipt.fetch('raw_sha256').each do |path, digest|
313
+ unless source.fetch('files_sha256').fetch(path) == digest && inputs.fetch('manifests').fetch(path) == receipt.fetch('normalized_sha256').fetch(path)
314
+ raise ContractError, 'Own source metadata escaped verified source'
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ def self.validate_input_receipts(inputs, source, profile:, lineage:)
321
+ if lineage
322
+ raise ContractError, 'Conflicting own-version receipts' if inputs.has_key?('source_versions')
323
+
324
+ Release.validate_inputs(inputs, source, profile)
325
+ else
326
+ raise ContractError, 'Unproven own-version normalization' if inputs.has_key?('release_versions')
327
+
328
+ Release.validate_json_preparation(inputs, source)
329
+ SourceVersions.validate_inputs(inputs, source) if inputs.has_key?('source_versions')
330
+ inputs.fetch('manifests').each do |path, digest|
331
+ next if Release::JSON_GEMFILES.include?(path) || (inputs.has_key?('source_versions') && SourceVersions::PATHS.include?(path))
332
+
333
+ raise ContractError, 'Dependency manifest escaped source' unless source.fetch('files_sha256').fetch(path) == digest
334
+ end
335
+ end
336
+ end
337
+
207
338
  def self.validate_source_pair(profile:, baseline_sha:, candidate_sha:)
208
339
  raise ContractError, 'Baseline and candidate must differ' if baseline_sha == candidate_sha
209
340
  return unless profile == 'weekly' && baseline_sha != REFERENCE
@@ -211,6 +342,19 @@ module HaveAPI
211
342
  raise ContractError, 'Weekly baseline must be the fixed reference'
212
343
  end
213
344
 
345
+ def self.push_pair!(selection, baseline:, candidate:)
346
+ return false unless selection['push_pair']
347
+
348
+ expected = { 'event_name' => 'push', 'before' => baseline, 'after' => candidate }
349
+ unless selection['event_name'] == 'push' && selection['profile'] == 'quick' && selection['push_pair'] == expected &&
350
+ selection['baseline_sha'] == baseline && selection['candidate_sha'] == candidate && baseline != candidate &&
351
+ [baseline, candidate].all? { |sha| sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40 }
352
+ raise ContractError, 'Invalid quick push event pair'
353
+ end
354
+
355
+ true
356
+ end
357
+
214
358
  # Canonicalization ignores map order only. Arrays and scalar types retain
215
359
  # their meaning; duplicate JSON or symbol/string-normalized keys fail closed.
216
360
  module Identity
@@ -270,6 +414,9 @@ module HaveAPI
270
414
  end
271
415
 
272
416
  class Comparator
417
+ POLICY = 'per-revision-response-stability-v1'.freeze
418
+ SOURCE_SHA256 = Digest::SHA256.file(__FILE__).hexdigest.freeze
419
+
273
420
  def initialize(samples, budgets, budget_sha256:)
274
421
  @samples = samples
275
422
  @budgets = budgets
@@ -357,13 +504,33 @@ module HaveAPI
357
504
  raise ContractError, 'Loaded runtime not in verified source tree' unless selected.fetch('files_sha256').fetch(path) == sha256
358
505
  end
359
506
  inputs = observation.fetch('inputs')
360
- if lineage
361
- Release.validate_inputs(inputs, selected, @samples.fetch('profile'))
362
- elsif inputs.has_key?('release_versions')
363
- raise ContractError, 'Unproven own-version normalization'
364
- else
365
- Release.validate_json_preparation(inputs, selected)
507
+ if inputs.has_key?('benchmark')
508
+ tool = inputs.fetch('benchmark')
509
+ raise ContractError, 'Incomplete benchmark inputs' unless tool.keys.sort == BENCHMARK_INPUTS.sort
510
+
511
+ tool.each do |path, input|
512
+ unless Digest::SHA256.hexdigest(input.fetch('content')) == input.fetch('sha256') &&
513
+ @samples.fetch('inputs').fetch('benchmark').fetch(path) == input
514
+ raise ContractError, 'Benchmark input digest mismatch'
515
+ end
516
+ end
517
+ unless inputs.fetch('locks') == { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
518
+ raise ContractError, 'Benchmark lock identity mismatch'
519
+ end
520
+
521
+ inputs.fetch('contributor_flakes').each do |path, input|
522
+ unless %w[flake.nix flake.lock].include?(path) && Digest::SHA256.hexdigest(input.fetch('content')) == selected.fetch('files_sha256').fetch(path)
523
+ raise ContractError, 'Contributor flake escaped source'
524
+ end
525
+ end
526
+ unless inputs.fetch('contributor_flakes').keys.sort == %w[flake.lock flake.nix]
527
+ raise ContractError, 'Missing contributor flake provenance'
528
+ end
529
+
530
+ elsif inputs.has_key?('contributor_flakes')
531
+ raise ContractError, 'Contributor flake separation without benchmark inputs'
366
532
  end
533
+ Performance.validate_input_receipts(inputs, selected, profile: @samples.fetch('profile'), lineage:)
367
534
  unless observation.fetch('environment') == @samples['environment'] && Release.graph(inputs) == @samples.fetch('inputs') &&
368
535
  observation.fetch('fixture_sha256') == @samples['fixture_sha256']
369
536
  raise ContractError, 'Worker provenance mismatch'
@@ -474,14 +641,28 @@ module HaveAPI
474
641
  end
475
642
  end
476
643
 
644
+ def output_counts(outputs, digest = 'canonical_sha256')
645
+ outputs.each_with_object(Hash.new(0)) do |output, counts|
646
+ identity = JSON.generate(output.values_at('http_status', 'envelope_status', digest))
647
+ counts[identity] += output.fetch('count')
648
+ end
649
+ end
650
+
477
651
  def same_outputs(left, right)
478
- summaries = [left, right].map do |outputs|
479
- outputs.each_with_object(Hash.new(0)) do |output, counts|
480
- identity = JSON.generate(output.values_at('http_status', 'envelope_status', 'canonical_sha256'))
481
- counts[identity] += output.fetch('count')
482
- end
652
+ output_counts(left) == output_counts(right)
653
+ end
654
+
655
+ def response_changes(pairs)
656
+ outputs = %w[baseline candidate].to_h do |role|
657
+ [role, pairs.flat_map { |pair| pair.fetch(role).fetch('outputs') }]
483
658
  end
484
- summaries.first == summaries.last
659
+ bytes = outputs.transform_values do |examples|
660
+ examples.sum { |output| output.fetch('body_bytes') * output.fetch('count') }.to_f / examples.sum { |output| output.fetch('count') }
661
+ end
662
+ delta = bytes.fetch('candidate') - bytes.fetch('baseline')
663
+ { 'raw_changed' => output_counts(outputs.fetch('baseline'), 'body_sha256') != output_counts(outputs.fetch('candidate'), 'body_sha256'),
664
+ 'canonical_changed' => !same_outputs(outputs.fetch('baseline'), outputs.fetch('candidate')),
665
+ 'bytes_per_request' => bytes.merge('delta' => delta, 'delta_percent' => 100.0 * delta / bytes.fetch('baseline')) }
485
666
  end
486
667
 
487
668
  def compare_case(item, budget)
@@ -499,7 +680,8 @@ module HaveAPI
499
680
  raise ContractError, 'Uncalibrated allocation ceiling' unless reference > 0 && ceiling == (reference * 1.25).ceil + 32
500
681
  end
501
682
 
502
- canonical_outputs = canonical_counters = nil
683
+ outputs_by_source = {}
684
+ canonical_counters = nil
503
685
  (self_pairs + pairs).each_with_index do |pair, index|
504
686
  expected_order = if index < self_pairs.length
505
687
  %w[baseline baseline]
@@ -530,8 +712,10 @@ module HaveAPI
530
712
  raise ContractError, 'Worker workload order mismatch'
531
713
  end
532
714
 
533
- canonical_outputs ||= sample.fetch('outputs')
534
- raise ContractError, 'Response identity differs between operations/revisions' unless same_outputs(canonical_outputs, sample['outputs'])
715
+ outputs_by_source[actual_sha] ||= sample.fetch('outputs')
716
+ unless same_outputs(outputs_by_source.fetch(actual_sha), sample.fetch('outputs'))
717
+ raise ContractError, "Response identity differs within revision #{actual_sha}"
718
+ end
535
719
  end
536
720
  end
537
721
  classifications = %w[wall_seconds cpu_seconds].to_h do |metric|
@@ -566,9 +750,10 @@ module HaveAPI
566
750
  'pass'
567
751
  end
568
752
  item.slice('id', 'dimensions', 'iterations')
569
- .merge('classification' => classification, 'timing' => classifications,
753
+ .merge('classification' => classification, 'timing' => classifications, 'responses' => response_changes(pairs),
570
754
  'correctness' => { 'status' => 'pass', 'validated_samples' => 2 * (self_pairs.length + pairs.length),
571
- 'operations_per_sample' => iterations, 'distinct_responses' => canonical_outputs.length },
755
+ 'operations_per_sample' => iterations, 'distinct_responses' => outputs_by_source.fetch(@samples.fetch('baseline_sha')).length,
756
+ 'distinct_responses_by_source' => outputs_by_source.transform_values(&:length) },
572
757
  'pilot' => item.fetch('pilot', {}).slice('selected_iterations', 'warmup_iterations', 'target_seconds',
573
758
  'baseline_batch_seconds', 'adequate_signal', 'capped', 'calibration_adjusted'),
574
759
  'allocations' => { 'baseline_median' => base_allocations, 'candidate_median' => candidate_allocations,
@@ -0,0 +1,21 @@
1
+ let
2
+ pkgs = import (builtins.fetchTree {
3
+ type = "github";
4
+ owner = "NixOS";
5
+ repo = "nixpkgs";
6
+ rev = "02c087f3fe673c9e317f206a627cb6b910336dbe";
7
+ narHash = "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=";
8
+ }) { };
9
+ in
10
+ pkgs.mkShell {
11
+ name = "haveapi-benchmarks";
12
+ packages = with pkgs; [ ruby_3_4 git gnumake pkg-config openssl libffi ];
13
+ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
14
+ shellHook = ''
15
+ for name in ''${!BUNDLE@}; do unset "$name"; done
16
+ unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
17
+ export GEM_HOME="''${HAVEAPI_BENCHMARK_GEM_HOME:-${toString ./.}/.gems}"
18
+ export GEM_PATH="$GEM_HOME:${pkgs.ruby_3_4}/lib/ruby/gems/3.4.0"
19
+ export PATH="$GEM_HOME/bin:$PATH"
20
+ '';
21
+ }
@@ -0,0 +1,216 @@
1
+ # Run unbundled in the locked benchmark shell; optional generators are required.
2
+ # One shared mounted fixture excludes setup from the per-render measurements.
3
+ # rubocop:disable RSpec/InstanceVariable, RSpec/LeakyLocalVariable
4
+ require_relative '../performance_compare'
5
+ root = File.expand_path('../../../..', __dir__)
6
+ %w[servers/ruby clients/ruby clients/go clients/rust clients/typescript].each do |component|
7
+ $:.unshift(File.join(root, component, 'lib'))
8
+ end
9
+ require 'haveapi'
10
+ require 'rack/mock'
11
+ require 'base64'
12
+ require 'cgi'
13
+ require_relative '../../test_support/all_client_performance_fixture'
14
+
15
+ RSpec.configure { |config| config.fail_if_no_examples = true }
16
+
17
+ RSpec.describe HaveAPI::ClientExample, '.render' do
18
+ # Each mode has its own registry entry. New entries need reviewed expectations
19
+ # and a real supported profile here, rather than silently missing measurement.
20
+ profiles = {
21
+ '0' => nil, '2' => nil, '3' => nil, '5' => nil, '6' => nil,
22
+ 'javascript-callback' => nil, 'javascript-async' => nil,
23
+ 'typescript-callback' => nil, 'typescript-async' => nil,
24
+ 'python-sync' => nil, 'python-async' => nil,
25
+ 'go' => :go, 'rust-async' => :rust, 'rust-blocking' => :rust,
26
+ 'typescript-generated' => :typescript
27
+ }.freeze
28
+ lanes = %i[init basic token oauth2 public protected failure].freeze
29
+ let(:matrix) { profiles }
30
+
31
+ before(:all) do
32
+ @server = HaveAPI::AllClientPerformanceFixture.build(resources: 2)
33
+ @profiles = @server.documentation_client_profiles(1)
34
+ @description = description(@server, 'admin')
35
+ @large_description = Marshal.load(Marshal.dump(@description))
36
+ unrelated = @large_description.fetch(:resources).fetch('resource1')
37
+ unrelated[:resources] = 128.times.to_h { |i| ["unrelated#{i}", Marshal.load(Marshal.dump(@description[:resources]['resource1']))] }
38
+ @measurements = []
39
+ end
40
+
41
+ after(:all) do
42
+ RSpec.configuration.reporter.message("ALL_CLIENT_PERFORMANCE #{JSON.generate(registry: profiles.keys.sort, lanes:, measurements: @measurements)}")
43
+ end
44
+
45
+ def headers(user)
46
+ { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
47
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}" }
48
+ end
49
+
50
+ def description(server, user)
51
+ server.describe_version(HaveAPI::Context.new(server, version: 1,
52
+ user: HaveAPI::DocumentationPerformanceFixture::User.new(user), params: {}))
53
+ end
54
+
55
+ def context(description, profile, action_name: 'protected', sample_index: 0, resource_name: 'resource0')
56
+ resource = description.fetch(:resources).fetch(resource_name)
57
+ action = resource.fetch(:actions).fetch(action_name)
58
+ HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test',
59
+ base_url: 'http://example.test/benchmark/api/',
60
+ version_url: 'http://example.test/benchmark/api/v1/',
61
+ description:, profile:, resource_path: [resource_name.to_s],
62
+ resource:, action_name:, action:, sample: action.fetch(:examples).fetch(sample_index))
63
+ end
64
+
65
+ def render(client, lane, ctx)
66
+ if %i[basic token oauth2].include?(lane)
67
+ client.render(:auth, context: ctx, auth_method: lane, auth_description: ctx.description.fetch(:authentication).fetch(lane))
68
+ else
69
+ client.render(lane == :init ? :init : :example, context: ctx)
70
+ end
71
+ end
72
+
73
+ def expect_result(result, id, lane)
74
+ expect(result).to be_a(Hash), "#{id}/#{lane} returned no panel"
75
+ if id == 'typescript-generated' && lane == :oauth2
76
+ expect(result[:status]).to eq(:limitation)
77
+ expect(result[:reason]).to be_a(String)
78
+ expect(result[:reason]).not_to be_empty
79
+ else
80
+ expect(result[:status]).to eq(:code), "#{id}/#{lane}: #{result.inspect}"
81
+ expect(result[:code]).to be_a(String)
82
+ expect(result[:code]).not_to be_empty
83
+ if %w[0 3].include?(id) && lane == :oauth2
84
+ expect(result[:code]).to include('OAuth2 is not supported')
85
+ end
86
+ end
87
+ end
88
+
89
+ def allocations(count: 8, &block)
90
+ 2.times(&block)
91
+ Array.new(3) do
92
+ before = GC.stat(:total_allocated_objects)
93
+ count.times(&block)
94
+ (GC.stat(:total_allocated_objects) - before) / count.to_f
95
+ end.min
96
+ end
97
+
98
+ def calls(&block)
99
+ count = 0
100
+ trace = TracePoint.new(:call) { count += 1 }
101
+ trace.enable(&block)
102
+ count
103
+ ensure
104
+ trace&.disable
105
+ end
106
+
107
+ it 'requires exactly the reviewed registry and all real profiles' do
108
+ ids = described_class.clients.map(&:id)
109
+ expect(ids.sort).to eq(profiles.keys.sort)
110
+ expect(ids.uniq).to eq(ids)
111
+ expect(@profiles.keys.sort).to eq(%i[go rust typescript])
112
+ expect(@description.fetch(:authentication).keys.sort).to eq(%i[basic oauth2 token])
113
+ end
114
+
115
+ profiles.each do |id, language|
116
+ lanes.each do |lane|
117
+ it "bounds #{id}/#{lane} work and allocations with unrelated descendants" do
118
+ client = described_class.clients.find { |item| item.id == id }
119
+ expect(client).not_to be_nil
120
+ profile = language && @profiles.fetch(language)
121
+ options = { action_name: lane == :public ? 'public' : 'protected', sample_index: lane == :failure ? 1 : 0 }
122
+ small = context(@description, profile, **options)
123
+ large = context(@large_description, profile, **options)
124
+ expect(client.available?(small)).to be(true)
125
+ expect(client.available?(large)).to be(true)
126
+ before = Marshal.dump([small.description, large.description])
127
+ expected = render(client, lane, small)
128
+ expect_result(expected, id, lane)
129
+ expect(render(client, lane, large)).to eq(expected)
130
+ small_calls = calls { render(client, lane, small) }
131
+ large_calls = calls { render(client, lane, large) }
132
+ small_allocations = allocations { render(client, lane, small) }
133
+ large_allocations = allocations { render(client, lane, large) }
134
+ @measurements << { id:, lane:, allocations: [small_allocations, large_allocations], calls: [small_calls, large_calls] }
135
+ # Selected action/provider/sample is unchanged. Allow fixed VM/lookup
136
+ # slack, while recursive walks of the 128 unrelated subtrees must fail.
137
+ expect(large_calls).to be <= small_calls + 32
138
+ expect(large_allocations).to be <= small_allocations + 32
139
+ expect(Marshal.dump([small.description, large.description])).to eq(before)
140
+ end
141
+ end
142
+ end
143
+
144
+ def expect_panel(body, anchor, result)
145
+ panel = body.match(%r{<div id="#{Regexp.escape(anchor)}">\s*(.*?)</div>}m)
146
+ expect(panel).not_to be_nil, "Missing #{anchor}"
147
+ text = result.fetch(result[:status] == :code ? :code : :reason)
148
+ expect(panel[1]).to include(CGI.escapeHTML(text))
149
+ expect(panel[1]).to include(result[:status] == :code ? '<pre><code' : 'client-limitation')
150
+ end
151
+
152
+ def expect_complete_page(server, user, resources)
153
+ desc = description(server, user)
154
+ response = Rack::MockRequest.new(server.app).get('/benchmark/api/v1/', **headers(user))
155
+ expect(response.status).to eq(200)
156
+ expect(response['content-type']).to start_with('text/html')
157
+ expect(response.body).to include('PUBLIC_SAMPLE')
158
+ %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only].each do |marker|
159
+ expect(response.body.include?(marker)).to eq(user == 'admin'), marker
160
+ end
161
+ example_count = 0
162
+ HaveAPI::ClientExample.clients.each do |client|
163
+ language = matrix.fetch(client.id)
164
+ profile = language && server.documentation_client_profiles(1).fetch(language)
165
+ ctx = context(desc, profile)
166
+ expect(client.available?(ctx)).to be(true)
167
+ expect_panel(response.body, "init-#{client.id}", render(client, :init, ctx))
168
+ desc.fetch(:authentication).each_key do |method|
169
+ result = render(client, method, ctx)
170
+ expect_result(result, client.id, method)
171
+ expect_panel(response.body, "auth-#{method}-#{client.id}", result)
172
+ end
173
+ resources.times do |index|
174
+ resource_name = "resource#{index}"
175
+ desc.fetch(:resources).fetch(resource_name).fetch(:actions).each do |action_name, action|
176
+ action.fetch(:examples).each_index do |sample_index|
177
+ selected = context(desc, profile, action_name: action_name.to_s, sample_index:, resource_name:)
178
+ result = client.render(:example, context: selected)
179
+ expect_result(result, client.id, :example)
180
+ expect_panel(response.body, "example-#{resource_name}-#{action_name}-#{sample_index}-#{client.id}", result)
181
+ example_count += 1
182
+ end
183
+ end
184
+ end
185
+ end
186
+ expected_samples = (resources * 2 * (user == 'admin' ? 3 : 2)) + (user == 'admin' ? 1 : 0)
187
+ expect(example_count).to eq(expected_samples * matrix.length)
188
+ response
189
+ end
190
+
191
+ it 'renders every real panel and isolates admin/member/admin pages through the same adapters' do
192
+ first = expect_complete_page(@server, 'admin', 2)
193
+ expect_complete_page(@server, 'member', 2)
194
+ last = expect_complete_page(@server, 'admin', 2)
195
+ expect(last.body).to eq(first.body)
196
+ end
197
+
198
+ it 'bounds complete all-profile page allocations by linear output growth' do
199
+ measurements = [2, 4, 8].map do |resources|
200
+ server = HaveAPI::AllClientPerformanceFixture.build(resources:)
201
+ response = expect_complete_page(server, 'admin', resources)
202
+ request = Rack::MockRequest.new(server.app)
203
+ allocated = allocations(count: 1) { request.get('/benchmark/api/v1/', **headers('admin')) }
204
+ { resources:, bytes: response.body.bytesize, allocations: allocated }
205
+ end
206
+ @measurements << { pages: measurements }
207
+ small, medium, large = measurements
208
+ expect(large[:bytes]).to be > medium[:bytes]
209
+ expect(medium[:bytes]).to be > small[:bytes]
210
+ expect(medium[:allocations]).to be > small[:allocations]
211
+ # Doubling the added resources doubles actual panels/output. A 2.5x
212
+ # incremental allowance covers bookkeeping; quadratic copying approaches 4x.
213
+ expect(large[:allocations] - medium[:allocations]).to be <= 2.5 * (medium[:allocations] - small[:allocations])
214
+ end
215
+ end
216
+ # rubocop:enable RSpec/InstanceVariable, RSpec/LeakyLocalVariable