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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative '../../benchmarks/performance_compare'
|
|
6
|
+
require_relative 'support/samples'
|
|
7
|
+
|
|
8
|
+
describe HaveAPI::Performance do
|
|
9
|
+
let(:root) { File.expand_path('../../../..', __dir__) }
|
|
10
|
+
let(:comparison) { File.join(root, 'servers/ruby/benchmarks/performance_compare.rb') }
|
|
11
|
+
|
|
12
|
+
def with_newer_json
|
|
13
|
+
Dir.mktmpdir('haveapi-extra-json-') do |directory|
|
|
14
|
+
FileUtils.mkdir_p(File.join(directory, 'specifications'))
|
|
15
|
+
library = File.join(directory, 'gems/json-3.1.0/lib')
|
|
16
|
+
FileUtils.mkdir_p(library)
|
|
17
|
+
File.write(File.join(library, 'json.rb'), "module JSON; VERSION = '3.1.0'; end\n")
|
|
18
|
+
spec = Gem::Specification.new do |gem|
|
|
19
|
+
gem.name = 'json'
|
|
20
|
+
gem.version = '3.1.0'
|
|
21
|
+
gem.summary = 'Owned activation control'
|
|
22
|
+
gem.authors = ['Fixture']
|
|
23
|
+
end
|
|
24
|
+
File.write(File.join(directory, 'specifications/json-3.1.0.gemspec'), spec.to_ruby)
|
|
25
|
+
environment = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
|
|
26
|
+
environment.merge!('GEM_HOME' => directory,
|
|
27
|
+
'GEM_PATH' => [directory, Gem.loaded_specs.fetch('json').base_dir, Gem.default_dir].uniq.join(File::PATH_SEPARATOR))
|
|
28
|
+
yield directory, environment
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'loads the native calibrated version in each public entry despite a newer visible gem' do
|
|
33
|
+
with_newer_json do |_directory, environment|
|
|
34
|
+
[comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
|
|
35
|
+
program = <<~RUBY
|
|
36
|
+
visible = Gem::Specification.find_all_by_name('json').map { |spec| spec.version.to_s }
|
|
37
|
+
require #{entry.inspect}
|
|
38
|
+
puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('json').version.to_s,
|
|
39
|
+
parser: JSON::Ext::Parser.name, parsed: JSON.parse('{"value":42}'))
|
|
40
|
+
RUBY
|
|
41
|
+
output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
|
|
42
|
+
expect(status).to be_success, errors
|
|
43
|
+
result = JSON.parse(output)
|
|
44
|
+
expect(result.fetch('visible')).to include('3.1.0')
|
|
45
|
+
expect(result).to include('loaded' => '2.21.2', 'parser' => 'JSON::Ext::Parser', 'parsed' => { 'value' => 42 })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'uses the same activation in the neutral worker serialization path' do
|
|
51
|
+
with_newer_json do |_directory, environment|
|
|
52
|
+
program = <<~RUBY
|
|
53
|
+
require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
|
|
54
|
+
HaveAPI::Performance::Worker.define_method(:run) { { json_version: Gem.loaded_specs.fetch('json').version.to_s } }
|
|
55
|
+
exit HaveAPI::Performance::CLI.new(['worker']).execute
|
|
56
|
+
RUBY
|
|
57
|
+
output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program, stdin_data: '{}')
|
|
58
|
+
expect(status).to be_success, errors
|
|
59
|
+
expect(JSON.parse(output)).to include('json_version' => '2.21.2')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'refuses an already activated incompatible version instead of weakening the environment' do
|
|
64
|
+
with_newer_json do |_directory, environment|
|
|
65
|
+
program = "gem 'json', '= 3.1.0'; require 'json'; require #{comparison.inspect}"
|
|
66
|
+
_output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
|
|
67
|
+
expect(status).not_to be_success
|
|
68
|
+
expect(errors).to include('json', '2.21.2', 'already activated json-3.1.0')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'reports the required version when the calibrated gem is missing' do
|
|
73
|
+
with_newer_json do |directory, environment|
|
|
74
|
+
environment['GEM_PATH'] = directory
|
|
75
|
+
program = "Gem.use_paths(#{directory.inspect}, [#{directory.inspect}]); require #{comparison.inspect}"
|
|
76
|
+
_output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
|
|
77
|
+
expect(status).not_to be_success
|
|
78
|
+
expect(errors).to include('json', '2.21.2', '3.1.0')
|
|
79
|
+
expect(errors).to match(/Could not find/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'declares the calibrated JSON constraint in all three fresh development bundles' do
|
|
84
|
+
expected = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
|
|
85
|
+
.fetch('environment').fetch('weekly').fetch('gems').fetch('json').fetch('version')
|
|
86
|
+
expect(HaveAPI::Performance::JSON_VERSION).to eq(expected)
|
|
87
|
+
with_newer_json do |directory, environment|
|
|
88
|
+
HaveAPI::Performance::Release::JSON_GEMFILES.each do |path|
|
|
89
|
+
definition = Bundler::Dsl.evaluate(File.join(root, path), nil, {})
|
|
90
|
+
expect(definition.dependencies.select { |dependency| dependency.name == 'json' }.map { |dependency| dependency.requirement.to_s }).to eq(["= #{expected}"])
|
|
91
|
+
end
|
|
92
|
+
# Resolve only this installed constraint in an owned lockless fixture.
|
|
93
|
+
# No repository lock or external dependency is resolved or installed.
|
|
94
|
+
file = File.join(directory, 'Gemfile')
|
|
95
|
+
File.write(file, "source 'https://rubygems.org'\ngem 'json', '= #{expected}'\n")
|
|
96
|
+
_output, errors, status = Open3.capture3(environment, RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'lock', '--local', '--gemfile', file)
|
|
97
|
+
expect(status).to be_success, errors
|
|
98
|
+
expect(File.read("#{file}.lock")).to include("json (#{expected})", "json (= #{expected})")
|
|
99
|
+
expect(File.read("#{file}.lock")).not_to include('json (3.1.0)')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'canonicalizes only the exact known JSON preparation declaration and binds raw replay evidence' do
|
|
104
|
+
release = HaveAPI::Performance::Release
|
|
105
|
+
old = release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] }
|
|
106
|
+
pinned = old.transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
|
|
107
|
+
receipt = release.json_preparation(pinned)
|
|
108
|
+
expect(receipt.fetch('normalized_contents')).to eq(old)
|
|
109
|
+
expect(receipt.fetch('raw_sha256')).not_to eq(receipt.fetch('normalized_sha256'))
|
|
110
|
+
expect(receipt.fetch('declarations').values).to all(eq('exact-development-pin'))
|
|
111
|
+
unversioned = old.transform_values { |text| "#{text} gem 'json'\n" }
|
|
112
|
+
expect(release.json_preparation(unversioned).fetch('normalized_contents')).to eq(old)
|
|
113
|
+
['3.1.0', '>= 2.21.2'].each do |requirement|
|
|
114
|
+
changed = pinned.transform_values { |text| text.sub('= 2.21.2', requirement) }
|
|
115
|
+
expect { release.json_preparation(changed) }.to raise_error(HaveAPI::Performance::ContractError)
|
|
116
|
+
end
|
|
117
|
+
changed = pinned.merge('Gemfile' => "#{pinned.fetch('Gemfile')}# unrelated comment\ngem 'other'\n")
|
|
118
|
+
expect(release.json_preparation(changed).fetch('normalized_contents')).not_to eq(old)
|
|
119
|
+
inputs = { 'json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256') }
|
|
120
|
+
source = { 'files_sha256' => receipt.fetch('raw_sha256') }
|
|
121
|
+
expect { release.validate_json_preparation(inputs, source) }.not_to raise_error
|
|
122
|
+
%w[raw_sha256 normalized_sha256 declarations].each do |field|
|
|
123
|
+
forged = Marshal.load(Marshal.dump(inputs))
|
|
124
|
+
forged['json_preparation'][field]['Gemfile'] = 'forged'
|
|
125
|
+
expect { release.validate_json_preparation(forged, source) }.to raise_error(HaveAPI::Performance::ContractError)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'replays the exact preparation delta while rejecting other content and raw source changes' do
|
|
130
|
+
release = HaveAPI::Performance::Release
|
|
131
|
+
%w[exact other-content raw-source].each do |control|
|
|
132
|
+
samples, budgets = PerformanceSamples.fixture
|
|
133
|
+
samples.fetch('observations').each_value do |observation|
|
|
134
|
+
next unless observation.fetch('source').fetch('sha') == samples.fetch('candidate_sha')
|
|
135
|
+
|
|
136
|
+
contents = observation.fetch('inputs').fetch('json_preparation').fetch('contents')
|
|
137
|
+
.transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
|
|
138
|
+
contents['Gemfile'] << "# other content\n" if control == 'other-content'
|
|
139
|
+
receipt = release.json_preparation(contents)
|
|
140
|
+
observation['inputs'].merge!('json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256'))
|
|
141
|
+
samples['sources']['candidate']['files_sha256'].merge!(receipt.fetch('raw_sha256')) unless control == 'raw-source'
|
|
142
|
+
end
|
|
143
|
+
comparator = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budgets), budget_sha256: samples.fetch('budget_sha256'))
|
|
144
|
+
if control == 'exact'
|
|
145
|
+
expect(comparator.compare.fetch('status')).to eq('pass')
|
|
146
|
+
else
|
|
147
|
+
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,431 @@
|
|
|
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 = described_class.version(original)
|
|
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
|
+
it 'verifies the complete published binary delta and all four exact trees in an owned history' do
|
|
89
|
+
expect(proof).to include('reference_sha' => HaveAPI::Performance::REFERENCE, 'mapped_sha' => described_class::MAPPED,
|
|
90
|
+
'metadata_sha' => described_class::METADATA, 'trees' => described_class::TREES)
|
|
91
|
+
expect(Digest::SHA256.hexdigest(proof.fetch('reference_delta'))).to eq(described_class::DELTA)
|
|
92
|
+
expect(proof.fetch('reference_delta')).to eq(proof.fetch('metadata_delta'))
|
|
93
|
+
expect(repository.ancestor?(HaveAPI::Performance::REFERENCE, head)).to be(false)
|
|
94
|
+
expect(repository.ancestor?(described_class::MAPPED, head)).to be(true)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'keeps the proof canonical with abbreviated IDs and changed Git display defaults' do
|
|
98
|
+
[4, 7, 12].each do |abbreviation|
|
|
99
|
+
fixture_git(git_fixture_root, 'config', 'core.abbrev', abbreviation.to_s)
|
|
100
|
+
fixture_git(git_fixture_root, 'config', 'diff.context', '0')
|
|
101
|
+
fixture_git(git_fixture_root, 'config', 'diff.noprefix', 'true')
|
|
102
|
+
fixture_git(git_fixture_root, 'config', 'diff.suppressBlankEmpty', 'true')
|
|
103
|
+
fixture_git(git_fixture_root, 'config', 'diff.external', 'false')
|
|
104
|
+
expect(repository.release_lineage(head)).to eq(proof)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'accepts ordinary master ancestry in a shallow clone without maintenance objects' do
|
|
109
|
+
Dir.mktmpdir('haveapi-shallow-master-') do |directory|
|
|
110
|
+
fixture_git(directory, 'clone', '--quiet', '--depth=1', '--single-branch', '--branch=master',
|
|
111
|
+
"file://#{git_fixture_root}", '.')
|
|
112
|
+
shallow = HaveAPI::PerformanceCI::Repository.new(directory)
|
|
113
|
+
expect { shallow.git('cat-file', '-e', described_class::MAPPED) }.to raise_error(HaveAPI::PerformanceCI::Error)
|
|
114
|
+
expect(shallow.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'rejects modified metadata, reference trees and candidate binding during replay' do
|
|
119
|
+
%w[metadata_delta reference_delta trees candidate_sha].each do |field|
|
|
120
|
+
changed = Marshal.load(Marshal.dump(proof))
|
|
121
|
+
changed[field] = field == 'trees' ? ['f' * 40] * 4 : 'wrong'
|
|
122
|
+
expect { described_class.validate_lineage(changed, head) }.to raise_error(error, /lineage/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'does not infer the mapping for nonancestor candidates and fails on missing history' do
|
|
127
|
+
expect(repository.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
|
|
128
|
+
expect { repository.release_lineage('f' * 40) }.to raise_error(HaveAPI::PerformanceCI::Error, /ancestry/)
|
|
129
|
+
git = ->(*) { raise HaveAPI::PerformanceCI::Error, 'missing metadata object' }
|
|
130
|
+
expect do
|
|
131
|
+
described_class.lineage(git:, ancestor: ->(left, _right) { left == described_class::MAPPED }, candidate: head)
|
|
132
|
+
end.to raise_error(HaveAPI::PerformanceCI::Error, /missing metadata/)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'rejects a wrong full tree delta on the live proof path' do
|
|
136
|
+
git = lambda do |*arguments|
|
|
137
|
+
result = repository.git(*arguments)
|
|
138
|
+
arguments.include?('diff') ? "#{result}\n# changed" : result
|
|
139
|
+
end
|
|
140
|
+
expect do
|
|
141
|
+
described_class.lineage(git:, ancestor: repository.method(:ancestor?), candidate: head)
|
|
142
|
+
end.to raise_error(error, /lineage/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'uses the same proof in the direct runner and limits nonliteral ancestry to weekly' do
|
|
146
|
+
%w[weekly quick missing wrong-delta].each do |mode|
|
|
147
|
+
profile = mode == 'quick' ? 'quick' : 'weekly'
|
|
148
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-sha', HaveAPI::Performance::REFERENCE,
|
|
149
|
+
'--candidate-sha', head, '--profile', profile,
|
|
150
|
+
'--baseline-root', root, '--candidate-root', root,
|
|
151
|
+
'--budgets', 'unused', '--output', 'unused'])
|
|
152
|
+
cli.send(:parse)
|
|
153
|
+
allow(cli).to receive(:verify_source) { |role| { root: "/controlled/#{role}", sha: head } }
|
|
154
|
+
allow(cli).to receive(:git) { |*arguments| repository.git(*arguments) }
|
|
155
|
+
allow(cli).to receive(:ancestor?) { |left, right| repository.ancestor?(left, right) }
|
|
156
|
+
if mode == 'missing'
|
|
157
|
+
allow(cli).to receive(:ancestor?).and_return(false)
|
|
158
|
+
elsif mode == 'wrong-delta'
|
|
159
|
+
allow(cli).to receive(:git).with(*described_class::DIFF, HaveAPI::Performance::REFERENCE, described_class::MAPPED).and_return('wrong')
|
|
160
|
+
end
|
|
161
|
+
allow(cli).to receive(:child).and_raise(error, 'unexpected worker')
|
|
162
|
+
if mode == 'weekly'
|
|
163
|
+
allow(cli).to receive(:child).with('baseline', {}, 'pilot').and_raise(error, 'verified pilot boundary')
|
|
164
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, 'verified pilot boundary')
|
|
165
|
+
expect(cli).to have_received(:child).with('baseline', {}, 'pilot')
|
|
166
|
+
else
|
|
167
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, /ancestor|lineage/)
|
|
168
|
+
expect(cli).not_to have_received(:child)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'normalizes only synchronized own patch identities, preserving every external input' do
|
|
174
|
+
%w[quick weekly].each do |profile|
|
|
175
|
+
old = described_class.capture(contents('0.30.1', profile:))
|
|
176
|
+
new = described_class.capture(contents('0.30.2', profile:))
|
|
177
|
+
expect(old.fetch('raw_sha256')).not_to eq(new.fetch('raw_sha256'))
|
|
178
|
+
expect(old.fetch('normalized_sha256')).to eq(new.fetch('normalized_sha256'))
|
|
179
|
+
expect(new.fetch('own_version')).to eq('0.30.2')
|
|
180
|
+
%w[flake.lock flake.nix .ruby-version Gemfile].each do |path|
|
|
181
|
+
changed = contents('0.30.2', profile:)
|
|
182
|
+
changed[path] += "\n# arbitrary input change\n"
|
|
183
|
+
expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
|
|
184
|
+
end
|
|
185
|
+
%w[required_ruby_version summary activesupport].each do |field|
|
|
186
|
+
changed = contents('0.30.2', profile:)
|
|
187
|
+
changed['servers/ruby/haveapi.gemspec'] = changed.fetch('servers/ruby/haveapi.gemspec').sub(field, "changed_#{field}")
|
|
188
|
+
expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'rejects mismatched own constants, package names, constraints and minor versions' do
|
|
194
|
+
[
|
|
195
|
+
['VERSION', "0.31.2\n"],
|
|
196
|
+
['VERSION', " 0.30.2\n"],
|
|
197
|
+
['servers/ruby/lib/haveapi/version.rb', "module HaveAPI\n VERSION = '0.30.3'\nend\n"],
|
|
198
|
+
['servers/ruby/haveapi.gemspec', "spec.name = 'unrelated'\n"],
|
|
199
|
+
['servers/ruby/haveapi.gemspec', "spec.name = 'haveapi'\nspec.add_dependency 'haveapi-client', '~> 0.30.3'\n"]
|
|
200
|
+
].each do |path, value|
|
|
201
|
+
expect { described_class.capture(contents('0.30.2').merge(path => value)) }.to raise_error(error)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'retains actual worker input receipts and rejects mismatched loaded local components' do
|
|
206
|
+
# Provide owned lock bytes at the input boundary. The production worker still
|
|
207
|
+
# refuses a missing root lock; component-only CI need not install that bundle.
|
|
208
|
+
Dir.mktmpdir('haveapi-release-locks-') do |harness|
|
|
209
|
+
FileUtils.mkdir_p(File.join(harness, 'servers/ruby'))
|
|
210
|
+
File.write(File.join(harness, 'Gemfile.lock'), "controlled tooling lock\n")
|
|
211
|
+
File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
|
|
212
|
+
FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
|
|
213
|
+
File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
|
|
214
|
+
stub_const('HaveAPI::RustClient::VERSION', HaveAPI::VERSION) unless defined?(HaveAPI::RustClient::VERSION)
|
|
215
|
+
stub_const('HaveAPI::Performance::ROOT', harness)
|
|
216
|
+
ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
|
|
217
|
+
preparation = ordinary.fetch('json_preparation')
|
|
218
|
+
expect(preparation.fetch('declarations').values).to all(eq('exact-development-pin'))
|
|
219
|
+
expect(ordinary.fetch('manifests').slice(*described_class::JSON_GEMFILES)).to eq(preparation.fetch('normalized_sha256'))
|
|
220
|
+
expect do
|
|
221
|
+
described_class.validate_json_preparation(ordinary, 'files_sha256' => preparation.fetch('raw_sha256'))
|
|
222
|
+
end.not_to raise_error
|
|
223
|
+
captured = HaveAPI::Performance::Worker.inputs(root, 'quick', release: true)
|
|
224
|
+
expect(captured[:locks]).to have_key('clients/rust/Gemfile.lock')
|
|
225
|
+
expect(captured[:manifests]).to have_key('clients/rust/Gemfile')
|
|
226
|
+
expect(captured.fetch(:release_versions)).to eq(described_class.capture(contents(File.read(File.join(root, 'VERSION')).strip)))
|
|
227
|
+
Dir.mktmpdir('haveapi-release-inputs-') do |source|
|
|
228
|
+
contents('0.30.99').each do |path, value|
|
|
229
|
+
FileUtils.mkdir_p(File.dirname(File.join(source, path)))
|
|
230
|
+
File.write(File.join(source, path), value)
|
|
231
|
+
end
|
|
232
|
+
expect do
|
|
233
|
+
HaveAPI::Performance::Worker.inputs(source, 'quick', release: true)
|
|
234
|
+
end.to raise_error(error, /Wrong loaded local package version/)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def framework_footer(version)
|
|
240
|
+
%(<p class="text-right">Generated by <a href="https://github.com/vpsfreecz/haveapi">HaveAPI</a> v#{version}</p>)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def served_html
|
|
244
|
+
server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1)
|
|
245
|
+
response = Rack::MockRequest.new(server.app).get('http://example.test/benchmark/api/v1/',
|
|
246
|
+
'HTTP_HOST' => 'example.test', 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64('admin:fixture-only')}")
|
|
247
|
+
expect(response.status).to eq(200)
|
|
248
|
+
response.body
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def html_samples(served: nil)
|
|
252
|
+
samples, budget_json = PerformanceSamples.fixture
|
|
253
|
+
samples['release_lineage'] = proof.merge('candidate_sha' => samples.fetch('candidate_sha'))
|
|
254
|
+
normalized = nil
|
|
255
|
+
samples.fetch('sources').each do |role, source|
|
|
256
|
+
receipt = described_class.capture(contents(role == 'baseline' ? '0.30.1' : '0.30.2'))
|
|
257
|
+
source['files_sha256'].merge!(receipt.fetch('raw_sha256'))
|
|
258
|
+
inputs = { 'manifests' => receipt.fetch('normalized_sha256'), 'locks' => samples.fetch('inputs').fetch('locks'),
|
|
259
|
+
'release_versions' => receipt }
|
|
260
|
+
normalized ||= described_class.graph(inputs)
|
|
261
|
+
samples.fetch('observations').each_value do |observation|
|
|
262
|
+
next unless observation.fetch('source').fetch('root') == source.fetch('root')
|
|
263
|
+
|
|
264
|
+
observation['inputs'] = inputs
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
samples['inputs'] = normalized
|
|
268
|
+
id = 'docs.html_resources'
|
|
269
|
+
samples['inventory'].first['id'] = id
|
|
270
|
+
item = samples.fetch('cases').first
|
|
271
|
+
item['id'] = id
|
|
272
|
+
(item.fetch('self_pairs') + item.fetch('pairs')).each do |pair|
|
|
273
|
+
%w[baseline candidate].each do |role|
|
|
274
|
+
sample = pair.fetch(role)
|
|
275
|
+
own = samples.fetch('observations').fetch(sample.fetch('observation')).fetch('inputs').fetch('release_versions').fetch('own_version')
|
|
276
|
+
body = if served
|
|
277
|
+
source_version = File.read(File.join(root, 'VERSION')).strip
|
|
278
|
+
served.gsub("https://webui.haveapi.org/v#{source_version}/#", "https://webui.haveapi.org/v#{own}/#")
|
|
279
|
+
.sub(framework_footer(source_version), framework_footer(own))
|
|
280
|
+
else
|
|
281
|
+
"<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)}"
|
|
282
|
+
end
|
|
283
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
284
|
+
sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
|
|
285
|
+
sample['outputs'] = [{ 'http_status' => 200, 'envelope_status' => nil, 'body_bytes' => body.bytesize, 'body_sha256' => digest, 'count' => sample.fetch('operations'),
|
|
286
|
+
'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, own)),
|
|
287
|
+
'comparison' => { 'kind' => 'own-webui-href', 'own_version' => own } }]
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
budgets = JSON.parse(budget_json)
|
|
291
|
+
budgets['profiles']['quick'].first['id'] = id
|
|
292
|
+
samples['budget_sha256'] = Digest::SHA256.hexdigest(JSON.generate(budgets))
|
|
293
|
+
[samples, budgets]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def compare(samples, budgets)
|
|
297
|
+
HaveAPI::Performance::Comparator.new(samples, budgets, budget_sha256: samples.fetch('budget_sha256')).compare
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it 'replays distinct raw bodies and input hashes through the narrow normalization' do
|
|
301
|
+
samples, budgets = html_samples
|
|
302
|
+
expect(compare(samples, budgets).fetch('status')).to eq('pass')
|
|
303
|
+
pair = samples.fetch('cases').first.fetch('pairs').first
|
|
304
|
+
expect(pair['baseline']['outputs'].first['body_sha256']).not_to eq(pair['candidate']['outputs'].first['body_sha256'])
|
|
305
|
+
expect(pair['baseline']['outputs'].first['canonical_sha256']).to eq(pair['candidate']['outputs'].first['canonical_sha256'])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it 'accepts real served footer/version changes and rejects a forged digest through the replay CLI' do
|
|
309
|
+
samples, budgets = html_samples(served: served_html)
|
|
310
|
+
Dir.mktmpdir('haveapi-release-replay-') do |directory|
|
|
311
|
+
policy = File.join(directory, 'budget.json')
|
|
312
|
+
input = File.join(directory, 'samples.json')
|
|
313
|
+
File.write(policy, JSON.generate(budgets))
|
|
314
|
+
# The CLI runs unchanged, with the same owned mini-history policy as this
|
|
315
|
+
# example. No production defect switch or fixture-history dependency exists.
|
|
316
|
+
fixture = File.join(directory, 'history.json')
|
|
317
|
+
File.write(fixture, JSON.generate(git_fixture))
|
|
318
|
+
adapter = File.join(directory, 'replay.rb')
|
|
319
|
+
File.write(adapter, <<~RUBY)
|
|
320
|
+
require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
|
|
321
|
+
history = JSON.parse(File.read(ARGV.shift))
|
|
322
|
+
performance = HaveAPI::Performance
|
|
323
|
+
performance.send(:remove_const, :REFERENCE)
|
|
324
|
+
performance.const_set(:REFERENCE, history.fetch('reference'))
|
|
325
|
+
{ 'MAPPED' => 'mapped', 'METADATA' => 'metadata', 'TREES' => 'trees', 'DELTA' => 'delta_sha256' }.each do |name, key|
|
|
326
|
+
performance::Release.send(:remove_const, name)
|
|
327
|
+
performance::Release.const_set(name, history.fetch(key))
|
|
328
|
+
end
|
|
329
|
+
exit performance::CLI.new(ARGV).execute
|
|
330
|
+
RUBY
|
|
331
|
+
[false, true].each do |tampered|
|
|
332
|
+
samples['cases'].first['pairs'].first['candidate']['outputs'].first['canonical_sha256'] = 'f' * 64 if tampered
|
|
333
|
+
File.write(input, JSON.generate(samples))
|
|
334
|
+
_output, _errors, status = Open3.capture3(RbConfig.ruby, adapter, fixture, 'compare',
|
|
335
|
+
'--input', input, '--budgets', policy,
|
|
336
|
+
'--output', File.join(directory, tampered ? 'tampered' : 'valid'))
|
|
337
|
+
expect(status.exitstatus).to eq(tampered ? 3 : 0)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it 'normalizes the actual worker HTML outside timing and retains its raw bytes' do
|
|
343
|
+
own = File.read(File.join(root, 'VERSION')).strip
|
|
344
|
+
body = "<a href=\"https://webui.haveapi.org/v#{own}/#api\">Connect</a>#{framework_footer(own)}"
|
|
345
|
+
scenario = HaveAPI::Performance::DocumentationScenario.allocate
|
|
346
|
+
allow(scenario).to receive(:reset!)
|
|
347
|
+
allow(scenario).to receive_messages(call: Rack::MockResponse.new(200, {}, body),
|
|
348
|
+
validate!: { http_status: 200, envelope_status: nil, body_bytes: body.bytesize,
|
|
349
|
+
body_sha256: Digest::SHA256.hexdigest(body), canonical_sha256: Digest::SHA256.hexdigest(body) },
|
|
350
|
+
counters: { operations: [{ queries: 0, authorizations: 0, executions: 0 }] })
|
|
351
|
+
worker = HaveAPI::Performance::Worker.new('source_root' => root, 'release_versions' => true)
|
|
352
|
+
worker.instance_variable_set(:@current_workload, 'docs.html_resources')
|
|
353
|
+
result = worker.measure(scenario, 1)
|
|
354
|
+
output = result.fetch(:outputs).first
|
|
355
|
+
expect(output.fetch('comparison')).to include('own_version' => File.read(File.join(root, 'VERSION')).strip)
|
|
356
|
+
expect(result.fetch(:bodies).values.first.fetch(:body)).to eq(body)
|
|
357
|
+
expect(output.fetch('canonical_sha256')).to eq(Digest::SHA256.hexdigest(described_class.html(body, output['comparison']['own_version'])))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it 'leaves lookalike attributes, other URLs, JSON and example code byte exact' do
|
|
361
|
+
[
|
|
362
|
+
'<a data-href="https://webui.haveapi.org/v0.30.2/#api">',
|
|
363
|
+
'<a href="https://other.test/v0.30.2/#api">',
|
|
364
|
+
'<a href="https://webui.haveapi.org.evil/v0.30.2/#api">',
|
|
365
|
+
'<a href="http://webui.haveapi.org/v0.30.2/#api">',
|
|
366
|
+
'<a href="https://webui.haveapi.org/v0.30.3/#api">',
|
|
367
|
+
'<a href="https://webui.haveapi.org/v0.30.2/other#api">',
|
|
368
|
+
'{"version":"0.30.2","url":"https://webui.haveapi.org/v0.30.2/#api"}',
|
|
369
|
+
'<a href="https://webui.haveapi.org/v0.30.2/#api">',
|
|
370
|
+
'<code><a href="https://webui.haveapi.org/v0.30.2/#api"></a></code>',
|
|
371
|
+
'<PRE><a href="https://webui.haveapi.org/v0.30.2/#api"></a></PRE>',
|
|
372
|
+
'<script>let x = \'<a href="https://webui.haveapi.org/v0.30.2/#api">\';</script>',
|
|
373
|
+
'<!-- <a href="https://webui.haveapi.org/v0.30.2/#api"> -->'
|
|
374
|
+
].each do |body|
|
|
375
|
+
expect(described_class.html(body + framework_footer('0.30.2'), '0.30.2')).to eq(body + framework_footer('0.30.PATCH'))
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it 'requires one exact framework footer matching the verified own version' do
|
|
380
|
+
footer = framework_footer('0.30.2')
|
|
381
|
+
[footer.sub('Generated by', 'Produced by'), footer.sub('github.com', 'other.test'),
|
|
382
|
+
framework_footer('0.30.3'), footer + footer, '<p>No footer</p>',
|
|
383
|
+
"<code>#{footer}</code>", "<!-- #{footer} -->", "<div data-example='#{footer}'></div>"].each do |body|
|
|
384
|
+
expect { described_class.html(body, '0.30.2') }.to raise_error(error, /footer/)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
it 'preserves footer-like examples, attributes and unrelated version strings' do
|
|
389
|
+
footer = framework_footer('0.30.2')
|
|
390
|
+
opaque = "<pre>#{footer}</pre><code>v0.30.2</code><!-- #{footer} --><div data-example='#{footer}'></div>"
|
|
391
|
+
expect(described_class.html(opaque + footer, '0.30.2')).to eq(opaque + framework_footer('0.30.PATCH'))
|
|
392
|
+
samples, budgets = html_samples(served: served_html)
|
|
393
|
+
sample = samples['cases'].first['pairs'].first['candidate']
|
|
394
|
+
body = "#{sample['bodies'].values.first['body']}<code>v0.30.2</code>"
|
|
395
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
396
|
+
sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
|
|
397
|
+
sample['outputs'].first.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
|
|
398
|
+
'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
|
|
399
|
+
expect { compare(samples, budgets) }.to raise_error(error, /Response identity/)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'rejects forged raw/normalized hashes, output differences and unverified input contents on replay' do
|
|
403
|
+
%w[raw normalized marker graph raw_graph api auth panels].each do |fault|
|
|
404
|
+
samples, budgets = html_samples
|
|
405
|
+
sample = samples.fetch('cases').first.fetch('pairs').first.fetch('candidate')
|
|
406
|
+
observation = samples.fetch('observations').fetch(sample.fetch('observation'))
|
|
407
|
+
output = sample.fetch('outputs').first
|
|
408
|
+
case fault
|
|
409
|
+
when 'raw' then sample['bodies'].values.first['body'] += 'tampered'
|
|
410
|
+
when 'normalized' then output['canonical_sha256'] = 'f' * 64
|
|
411
|
+
when 'marker' then output['comparison']['own_version'] = '0.30.1'
|
|
412
|
+
when 'graph' then observation['inputs']['release_versions']['normalized_sha256']['Gemfile'] = 'f' * 64
|
|
413
|
+
when 'raw_graph'
|
|
414
|
+
receipt = described_class.capture(observation['inputs']['release_versions']['contents'].merge('Gemfile' => '# replaced'))
|
|
415
|
+
observation['inputs'].merge!('release_versions' => receipt, 'manifests' => receipt['normalized_sha256'])
|
|
416
|
+
else
|
|
417
|
+
raw = sample['bodies'].values.first['body']
|
|
418
|
+
body = case fault
|
|
419
|
+
when 'api' then "#{raw}<pre>{\"version\":\"0.30.2\"}</pre>"
|
|
420
|
+
when 'auth' then "#{raw}ADMIN_ONLY_REQUEST"
|
|
421
|
+
else raw.sub('init-examples', '')
|
|
422
|
+
end
|
|
423
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
424
|
+
sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
|
|
425
|
+
output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
|
|
426
|
+
'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
|
|
427
|
+
end
|
|
428
|
+
expect { compare(samples, budgets) }.to raise_error(error), fault
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|