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.
@@ -21,7 +21,8 @@ module HaveAPI
21
21
 
22
22
  KEYS = {
23
23
  go: %i[import_path module_version description_path generator_version],
24
- rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version]
24
+ rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version],
25
+ typescript: %i[package_name package_version snapshot_path generator_version]
25
26
  }.freeze
26
27
 
27
28
  def initialize
@@ -52,9 +53,12 @@ module HaveAPI
52
53
  raise Invalid, 'Documentation client profile selects an unavailable API version' unless unknown.empty?
53
54
 
54
55
  @registrations.each do |version, languages|
55
- snapshot = languages.dig(:rust, :snapshot)
56
- if snapshot && snapshot.fetch('api_version').to_s != version
57
- raise Invalid, 'Documentation Rust snapshot API version mismatch'
56
+ %i[rust typescript].each do |language|
57
+ snapshot = languages.dig(language, :snapshot)
58
+ if snapshot && snapshot.fetch('api_version').to_s != version
59
+ label = language == :rust ? 'Rust' : 'TypeScript'
60
+ raise Invalid, "Documentation #{label} snapshot API version mismatch"
61
+ end
58
62
  end
59
63
  end
60
64
  end
@@ -101,7 +105,7 @@ module HaveAPI
101
105
 
102
106
  def validate_config(language, config)
103
107
  unless config.is_a?(Hash) && (config.keys - KEYS.fetch(language)).empty? &&
104
- config.all? { |key, value| %i[source module_version crate_version].include?(key) || nonempty_string?(value) }
108
+ config.all? { |key, value| %i[source module_version crate_version package_version].include?(key) || nonempty_string?(value) }
105
109
  raise Invalid, 'Invalid documentation client profile fields'
106
110
  end
107
111
 
@@ -125,7 +129,11 @@ module HaveAPI
125
129
  def load_profile(language, config)
126
130
  safely do
127
131
  config = validate_config(language, config)
128
- language == :go ? load_go(config) : load_rust(config)
132
+ case language
133
+ when :go then load_go(config)
134
+ when :rust then load_rust(config)
135
+ when :typescript then load_typescript(config)
136
+ end
129
137
  end
130
138
  end
131
139
 
@@ -211,6 +219,12 @@ module HaveAPI
211
219
 
212
220
  arguments = { description:, api_version: version, protocol_version: HaveAPI::PROTOCOL_VERSION,
213
221
  mount_path: server.root }
222
+ if language == :typescript
223
+ adapter = HaveAPI::TypeScriptClient::Documentation.from_description(
224
+ **arguments, package_name: config[:name], package_version: config[:version]
225
+ )
226
+ return profile(:typescript, config, adapter)
227
+ end
214
228
  if config[:generator_config]
215
229
  snapshot = HaveAPI::RustClient::Snapshot.from_description(
216
230
  **arguments, source_url: "https://haveapi.invalid#{server.root}v#{version}/", profile: 'server-documentation'
@@ -237,6 +251,35 @@ module HaveAPI
237
251
  profile(:rust, config, adapter)
238
252
  end
239
253
 
254
+ def load_typescript(config)
255
+ name = config[:package_name]
256
+ version = config[:package_version]
257
+ unless nonempty_string?(name) && name.bytesize <= 214 &&
258
+ name.match?(%r{\A(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*\z}) &&
259
+ !%w[node_modules favicon.ico].include?(name) &&
260
+ (version.nil? || (version.is_a?(String) &&
261
+ version.match?(/\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/)))
262
+ raise Invalid, 'Invalid TypeScript documentation client package'
263
+ end
264
+
265
+ require 'haveapi/typescript_client/documentation'
266
+ unless config.fetch(:generator_version) == HaveAPI::TypeScriptClient::VERSION
267
+ raise Invalid, 'Documentation TypeScript generator version mismatch'
268
+ end
269
+
270
+ documentation = HaveAPI::TypeScriptClient::Documentation
271
+ unless documentation.respond_to?(:from_description) && documentation.respond_to?(:from_snapshot)
272
+ raise Invalid, 'Documentation TypeScript generator has an incompatible profile interface'
273
+ end
274
+
275
+ prepared = { name: name.dup.freeze, version: version&.dup&.freeze }.freeze
276
+ return prepared unless config.has_key?(:snapshot_path)
277
+
278
+ snapshot = JSON.parse(File.read(local_file(config[:snapshot_path])))
279
+ adapter = documentation.from_snapshot(snapshot:, package_name: name, package_version: version)
280
+ prepared.merge(snapshot:, profile: profile(:typescript, prepared, adapter)).freeze
281
+ end
282
+
240
283
  def profile(language, config, adapter)
241
284
  unless adapter.respond_to?(:resolve) && adapter.respond_to?(:render)
242
285
  raise Invalid, 'Documentation client generator has an incompatible adapter interface'
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.30.2'.freeze
3
+ VERSION = '0.31.1'.freeze
4
4
  end
@@ -20,7 +20,7 @@
20
20
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/js" target="_blank" rel="noopener">JavaScript and TypeScript (callback and async)</a></li>
21
21
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/python" target="_blank" rel="noopener">Python (sync and async)</a></li>
22
22
  <% settings.api_server.documentation_client_profiles(@v).each do |language, profile| %>
23
- <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></li>
23
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= { go: 'Go', rust: 'Rust (async and blocking)', typescript: 'TypeScript (generated)' }.fetch(language) %></a>: <code><%= escape_html(profile.package_name) %></code><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></li>
24
24
  <% end %>
25
25
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/php" target="_blank" rel="noopener">PHP</a></li>
26
26
  <li>
@@ -31,7 +31,7 @@
31
31
 
32
32
  <p>
33
33
  These examples target API v<%= escape_html(@v) %>. The framework and dynamic
34
- clients share HaveAPI package versions. Generated Go and Rust SDKs use their
34
+ clients share HaveAPI package versions. Generated Go, Rust and TypeScript SDKs use their
35
35
  own package versions. Their configured SDKs are expected to match this API.
36
36
  </p>
37
37
 
@@ -139,6 +139,20 @@ describe HaveAPI::Server, '#mount' do
139
139
  )
140
140
  end
141
141
 
142
+ def register_typescript
143
+ generator = Module.new
144
+ generator.const_set(:VERSION, HaveAPI::VERSION)
145
+ generator.const_set(:Documentation, documentation)
146
+ stub_const('HaveAPI::TypeScriptClient', generator)
147
+ allow(registry).to receive(:require).with('haveapi/typescript_client/documentation').and_return(true)
148
+ allow(documentation).to receive(:from_description) do |description:, **|
149
+ descriptions << description
150
+ adapter
151
+ end
152
+ allow(documentation).to receive(:from_snapshot).and_return(adapter)
153
+ server.documentation_clients(1, typescript: { package_name: '@example/widget-client' })
154
+ end
155
+
142
156
  it 'captures the complete selected version after auth, action-state and post-mount routes exist' do
143
157
  register
144
158
  server.action_state = Object.new
@@ -212,4 +226,35 @@ describe HaveAPI::Server, '#mount' do
212
226
  expect(server.app).to be_nil
213
227
  expect(server.documentation_client_profiles(1)).to be_empty
214
228
  end
229
+
230
+ it 'mounts a generated TypeScript panel beside dynamic tabs using only authorized request samples' do
231
+ register_typescript
232
+ server.mount('/mounted/api/')
233
+ expect(descriptions.size).to eq(1)
234
+ private_schema = descriptions.first
235
+ expect(private_schema).to be_frozen
236
+ expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
237
+ expect(private_schema.dig('resources', 'widget', 'actions')).to have_key('secret')
238
+ expect(request_as('admin', 'en').body).to include('PRIVATE_SAMPLE')
239
+ contexts.clear
240
+ response = request_as('member', 'en')
241
+ expect(response.status).to eq(200)
242
+ expect(response.body).to include('TypeScript (generated)', 'init-typescript-generated', 'init-typescript-callback',
243
+ 'init-typescript-async', 'example-widget-show-0-typescript-generated', '@example/widget-client')
244
+ expect(response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only')
245
+ selected = contexts.find { |context| context.action_name == 'show' }
246
+ expect(selected.version_url).to eq('http://example.org/mounted/api/v1/')
247
+ expect(selected.action.dig(:input, :parameters).keys).to eq([:visible])
248
+ expect(selected.sample[:request]).to eq(visible: 'public sample')
249
+ expect(documentation).to have_received(:from_description).once
250
+ end
251
+
252
+ it 'aborts the whole mount when the generated TypeScript adapter rejects the full declaration' do
253
+ register_typescript
254
+ allow(documentation).to receive(:from_description).and_raise('PRIVATE_TYPESCRIPT_DIAGNOSTIC')
255
+ expect { server.mount('/') }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid,
256
+ 'Configured documentation client schema or configuration is invalid')
257
+ expect(server.app).to be_nil
258
+ expect(server.documentation_client_profiles(1)).to be_empty
259
+ end
215
260
  end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ class TypeScriptDocumentationAdapterFixture
4
+ def render(kind:, context:, mode:, auth_method: nil, auth_description: nil); end
5
+ end
6
+
7
+ describe HaveAPI::ClientExamples::TsGeneratedClient do
8
+ let(:adapter) { instance_spy(TypeScriptDocumentationAdapterFixture) }
9
+ let(:profile) { Struct.new(:language, :adapter).new(:typescript, adapter).freeze }
10
+ let(:context) do
11
+ HaveAPI::ClientExample::Context.new(
12
+ version: '1.0', host: 'example.test', base_url: 'https://example.test/mount/',
13
+ version_url: 'https://example.test/mount/v1.0/', description: {},
14
+ resource_path: ['widget'], action_name: 'update', action: {}, sample: { request: {} }, profile:
15
+ )
16
+ end
17
+
18
+ it 'adds a separate generated variant while preserving dynamic TypeScript identifiers' do
19
+ expect(described_class.id).to eq('typescript-generated')
20
+ expect(described_class.label).to eq('TypeScript (generated)')
21
+ expect(described_class.code).to eq(:typescript)
22
+ expect(HaveAPI::ClientExamples::TsClient.id).to eq('typescript-callback')
23
+ expect(HaveAPI::ClientExamples::TsAsyncClient.id).to eq('typescript-async')
24
+ end
25
+
26
+ it 'is available only for a matching optional profile' do
27
+ expect(described_class.available?(nil)).to be(false)
28
+ expect(described_class.available?(context.with(profile: nil))).to be(false)
29
+ expect(described_class.available?(context.with(profile: Struct.new(:language).new(:rust)))).to be(false)
30
+ expect(described_class.available?(context)).to be(true)
31
+ end
32
+
33
+ it 'delegates the immutable authorized context and exact authentication metadata' do
34
+ result = { status: :code, code: 'generated TypeScript' }.freeze
35
+ allow(adapter).to receive(:render).and_return(result)
36
+ expect(described_class.render(:init, context:)).to eq(result)
37
+ expect(described_class.render(:auth, context:, auth_method: :token, auth_description: { http_header: 'X-Token' })).to eq(result)
38
+ expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :async)
39
+ expect(adapter).to have_received(:render).with(kind: :auth, context:, mode: :async, auth_method: :token,
40
+ auth_description: { http_header: 'X-Token' })
41
+ end
42
+
43
+ it 'binds only the supplied authorized sample and preserves limitations' do
44
+ sample = { request: { label: 'public' } }
45
+ result = { status: :limitation, reason: 'The saved SDK does not represent this sample.' }.freeze
46
+ allow(adapter).to receive(:render).and_return(result)
47
+ renderer = described_class.new('example.test', 'https://example.test/', '1.0').with_context(context)
48
+ expect(renderer.example(sample)).to eq(result)
49
+ expect(adapter).to have_received(:render).with(kind: :example, mode: :async, context: have_attributes(sample: sample))
50
+ expect(renderer.init).to eq(result)
51
+ expect(described_class.new('example.test', 'https://example.test/', '1.0').init).to include(status: :limitation)
52
+ end
53
+ end
@@ -0,0 +1,113 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+
4
+ describe HaveAPI::ClientExample::Profiles do
5
+ let(:profiles) { described_class.new }
6
+ let(:server) { instance_double(HaveAPI::Server, versions: ['1.0'], root: '/mounted/', describe_version: description) }
7
+ let(:adapter) do
8
+ Class.new do
9
+ def resolve(**); end
10
+ def render(**); end
11
+ end.new
12
+ end
13
+ let(:documentation) { Class.new }
14
+ let(:directory) { Dir.mktmpdir('haveapi-typescript-profile-') }
15
+
16
+ def snapshot_path
17
+ File.join(directory, 'snapshot.json')
18
+ end
19
+
20
+ def description
21
+ { resources: { widget: { actions: { show: { input: { parameters: { hidden: { default: 'PRIVATE_DEFAULT' } } }, examples: ['PRIVATE_SAMPLE'] } } } },
22
+ authentication: {}, meta: { namespace: '_meta' } }
23
+ end
24
+
25
+ before do
26
+ generator = Module.new
27
+ generator.const_set(:VERSION, HaveAPI::VERSION)
28
+ generator.const_set(:Documentation, documentation)
29
+ stub_const('HaveAPI::TypeScriptClient', generator)
30
+ allow(profiles).to receive(:require).with('haveapi/typescript_client/documentation').and_return(true)
31
+ allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
32
+ end
33
+
34
+ after { FileUtils.remove_entry(directory) }
35
+
36
+ def mount
37
+ profiles.mount(['1.0'])
38
+ profiles.finalize(server)
39
+ end
40
+
41
+ it 'defers one frozen private structural description and preserves omitted package version' do
42
+ config = { package_name: '@example/widget-client' }
43
+ profiles.register('1.0', typescript: config)
44
+ config[:package_name].replace('changed-client')
45
+ expect(documentation).not_to have_received(:from_description)
46
+ mount
47
+ expect(documentation).to have_received(:from_description).once do |**arguments|
48
+ expect(arguments).to include(api_version: '1.0', protocol_version: HaveAPI::PROTOCOL_VERSION, mount_path: '/mounted/',
49
+ package_name: '@example/widget-client', package_version: nil)
50
+ expect(arguments[:description]).to be_frozen
51
+ expect(JSON.generate(arguments[:description])).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE')
52
+ expect(arguments[:description].dig('resources', 'widget', 'actions', 'show', 'input', 'parameters')).to have_key('hidden')
53
+ end
54
+ profile = profiles.for_version('1.0').fetch(:typescript)
55
+ expect(profile).to have_attributes(language: :typescript, package_name: '@example/widget-client', package_version: nil)
56
+ expect(profile).to be_frozen
57
+ expect(profile.adapter).to be_frozen
58
+ expect(server).to have_received(:describe_version).once
59
+ end
60
+
61
+ it 'loads a saved SDK once at registration with explicit package and generator versions' do
62
+ File.write(snapshot_path, JSON.generate(api_version: '1.0'))
63
+ profiles.register('1.0', typescript: { package_name: 'widget-client', package_version: '1.2.3-beta.1+build.4',
64
+ snapshot_path:, generator_version: HaveAPI::VERSION })
65
+ File.unlink(snapshot_path)
66
+ mount
67
+ expect(documentation).to have_received(:from_snapshot).once.with(snapshot: { 'api_version' => '1.0' },
68
+ package_name: 'widget-client', package_version: '1.2.3-beta.1+build.4')
69
+ expect(server).not_to have_received(:describe_version)
70
+ end
71
+
72
+ it 'rejects unsafe names, nonconcrete versions and unexpected fields before loading the generator' do
73
+ ['../client', '@scope', 'https://example.test/client', 'UPPERCASE', 'client name', 'node_modules'].each do |name|
74
+ expect { profiles.register('1.0', typescript: { package_name: name }) }.to raise_error(described_class::Invalid, /package/)
75
+ end
76
+ ['latest', '^1.2.3', '1.2', '01.2.3', '1.2.3-01', false].each do |version|
77
+ expect { profiles.register('1.0', typescript: { package_name: 'client', package_version: version }) }.to raise_error(described_class::Invalid, /package/)
78
+ end
79
+ expect { profiles.register('1.0', typescript: { package_name: 'client', factory: 'custom' }) }.to raise_error(described_class::Invalid, /fields/)
80
+ expect(profiles).not_to have_received(:require)
81
+ end
82
+
83
+ it 'rejects missing generators, version mismatch, missing snapshots and incompatible factories' do
84
+ allow(profiles).to receive(:require).and_raise(LoadError)
85
+ expect { profiles.register('1.0', typescript: { package_name: 'client' }) }.to raise_error(described_class::Invalid, /not installed/)
86
+ allow(profiles).to receive(:require).and_return(true)
87
+ expect { profiles.register('1.0', typescript: { package_name: 'client', generator_version: '0.0.0' }) }.to raise_error(described_class::Invalid, /version mismatch/)
88
+ expect { profiles.register('1.0', typescript: { package_name: 'client', snapshot_path: '/missing/schema.json' }) }.to raise_error(described_class::Invalid, /absolute local/)
89
+ allow(documentation).to receive(:respond_to?).and_call_original
90
+ allow(documentation).to receive(:respond_to?).with(:from_snapshot).and_return(false)
91
+ expect { profiles.register('1.0', typescript: { package_name: 'client' }) }.to raise_error(described_class::Invalid, /incompatible profile/)
92
+ end
93
+
94
+ it 'rejects a different saved API version without publishing any profile' do
95
+ File.write(snapshot_path, JSON.generate(api_version: '2.0'))
96
+ profiles.register('1.0', typescript: { package_name: 'client', snapshot_path: })
97
+ expect { profiles.mount(['1.0']) }.to raise_error(described_class::Invalid, /TypeScript snapshot API version mismatch/)
98
+ expect(profiles.for_version('1.0')).to be_empty
99
+ end
100
+
101
+ it 'fails atomically and hides private diagnostics for invalid schemas or adapters' do
102
+ allow(documentation).to receive(:from_description).and_raise('PRIVATE_SCHEMA_DIAGNOSTIC')
103
+ profiles.register('1.0', typescript: { package_name: 'client' })
104
+ expect { mount }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
105
+ expect(profiles.for_version('1.0')).to be_empty
106
+ end
107
+
108
+ it 'rejects an adapter missing render or resolve during initialization' do
109
+ allow(documentation).to receive(:from_description).and_return(Object.new)
110
+ profiles.register('1.0', typescript: { package_name: 'client' })
111
+ expect { mount }.to raise_error(described_class::Invalid, /adapter interface/)
112
+ end
113
+ end
@@ -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 Go and Rust SDKs in overview, initialization, authentication and actions' do
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.write(input, JSON.generate(samples))
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 across revisions without retaining request order' do
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":1')
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
- expect(comparator.compare['status']).to eq('pass')
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
- expect(compare_cli(samples, budgets).first).to eq(0)
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