haveapi 0.31.2 → 0.32.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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +188 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +48 -14
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +411 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +1058 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/event_baseline_spec.rb +97 -2
  115. data/spec/performance/json_activation_spec.rb +78 -1
  116. data/spec/performance/release_lineage_spec.rb +4 -3
  117. data/spec/performance/runner_spec.rb +1 -0
  118. data/test_support/all_client_performance_fixture.rb +6 -10
  119. data/test_support/documentation_oauth_fixture.rb +54 -0
  120. data/test_support/documentation_performance_fixture.rb +19 -1
  121. data/test_support/dynamic_documentation_api.rb +35 -10
  122. data/test_support/dynamic_python_login_spec.rb +3 -11
  123. metadata +52 -3
@@ -0,0 +1,123 @@
1
+ require 'spec_helper'
2
+ require 'haveapi/documentation_validators'
3
+
4
+ describe HaveAPI::DocumentationValidators do
5
+ def render(name, options, type: nil)
6
+ described_class.render({ name => options }, type:)
7
+ end
8
+
9
+ it 'keeps every rule in a semantic subtable with scoped row headers' do
10
+ html = described_class.render({ present: { empty: false }, include: { values: [false, 0, ''] } })
11
+ expect(html).to include('<div class="docs-validators"><table class="docs-validator-table"><caption>Validation rules</caption>',
12
+ '<th scope="col">Rule</th><th scope="col">Details</th>',
13
+ '<tr class="docs-validator"><th scope="row">Present</th><td>',
14
+ '<tr class="docs-validator"><th scope="row">Include</th><td>')
15
+ expect(html.scan('<table').length).to eq(1)
16
+ expect(html.scan('<tr class="docs-validator">').length).to eq(2)
17
+ expect(html).not_to include('<h5>')
18
+ expect(described_class.render({})).to eq('')
19
+ expect(described_class.render(nil)).to eq('')
20
+ end
21
+
22
+ it 'explains required presence and its distinct empty-value modes' do
23
+ strict = render(:present, { empty: false, message: 'Required value' })
24
+ expect(strict).to include('Must be supplied and cannot be null.', 'Must not be empty;', 'non-whitespace', 'Required value')
25
+ expect(render(:present, { empty: true })).to include('Empty values are allowed.')
26
+ end
27
+
28
+ it 'renders inclusion choices as typed machine values with separate localized labels' do
29
+ values = { 'active' => 'Aktivní', 0 => 'Zero', false => 'Disabled', nil => 'Unset' }
30
+ html = render(:include, { values:, message: 'Select one' })
31
+ expect(html).to include('Allowed values:', '<code>active</code>: Aktivní', '<code>0</code>: Zero',
32
+ '<code>false</code>: Disabled', '<code>null</code>: Unset', 'Select one')
33
+ expect(html).not_to include('=&gt;', '{&quot;active&quot;')
34
+ expect(html.index('Aktivní')).to be < html.index('Zero')
35
+ end
36
+
37
+ it 'lists array alternatives, reserved exclusions and empty sets honestly' do
38
+ expect(render(:include, { values: ['', false, 0] })).to include('<code></code>', '<code>false</code>', '<code>0</code>')
39
+ expect(render(:exclude, { values: ['root'] })).to include('Disallowed values:', '<code>root</code>')
40
+ expect(render(:exclude, { values: ['root'] })).not_to include('Allowed values:')
41
+ expect(render(:include, { values: [] })).to include('The set is empty.')
42
+ expect(render(:include, { values: {} })).to include('The set is empty.')
43
+ end
44
+
45
+ %i[include exclude].each do |rule|
46
+ it "keeps #{rule} string contents including quotes and Unicode without adding presentation quotes" do
47
+ html = render(rule, { values: ['<tag>&"Žluťoučký"', "'quoted'", '', 'false', '0', 'null'] })
48
+ expect(html).to include('<ul class="docs-validator-values">', '<li><code>&lt;tag&gt;&amp;&quot;Žluťoučký&quot;</code></li>',
49
+ '<li><code>&#39;quoted&#39;</code></li>', '<li><code></code></li>',
50
+ '<li><code>false</code></li>', '<li><code>0</code></li>', '<li><code>null</code></li>')
51
+ expect(html).not_to include('<tag>', '&amp;lt;', 'The set is empty.', '<code>&quot;')
52
+ end
53
+
54
+ it "retains #{rule} nonstring types and nested JSON quoting" do
55
+ html = render(rule, { values: [true, false, 0, 1.5, nil, ['a"b', ''], { 'nested' => ['<tag>', nil] }] })
56
+ expect(html).to include('<li><code>true</code></li>', '<li><code>false</code></li>', '<li><code>0</code></li>',
57
+ '<li><code>1.5</code></li>', '<li><code>null</code></li>',
58
+ '<li><code>[&quot;a\&quot;b&quot;,&quot;&quot;]</code></li>',
59
+ '<li><code>{&quot;nested&quot;:[&quot;&lt;tag&gt;&quot;,null]}</code></li>')
60
+ end
61
+ end
62
+
63
+ it 'keeps inclusion hash labels and empty string keys separate from empty sets' do
64
+ html = render(:include, { values: { '' => 'Empty label <&>', '"quoted"' => false, 'žluťoučký' => ['nested'] } })
65
+ expect(html).to include('<li><code></code>: Empty label &lt;&amp;&gt;</li>',
66
+ '<li><code>&quot;quoted&quot;</code>: <code>false</code></li>',
67
+ '<li><code>žluťoučký</code>: <code>[&quot;nested&quot;]</code></li>')
68
+ expect(html).not_to include('The set is empty.', 'Empty label <&>', '&amp;lt;')
69
+ end
70
+
71
+ it 'keeps acceptance and confirmation values distinct without coercion' do
72
+ [nil, false, 0, '0'].each do |value|
73
+ expect(render(:accept, { value: })).to include("Must equal <code>#{CGI.escapeHTML(JSON.generate(value))}</code>")
74
+ end
75
+ expect(render(:confirm, { parameter: :password, equal: true })).to include('Must equal parameter <code>password</code>')
76
+ expect(render(:confirm, { parameter: 'password', equal: false })).to include('Must differ from parameter <code>password</code>')
77
+ end
78
+
79
+ it 'explains inclusive bounds, step origin, modulo and parity separately' do
80
+ html = render(:number, { min: 3, max: 10, step: 2, mod: 5, odd: true })
81
+ expect(html).to include('Minimum: <code>3</code> (inclusive)', 'Maximum: <code>10</code> (inclusive)',
82
+ 'Step: <code>2</code>, measured from <code>3</code>',
83
+ 'multiple of <code>5</code>, measured from zero', 'Must be odd.', 'Must be a number after parameter type conversion.')
84
+ expect(render(:number, { step: 2, even: true, odd: false })).to include('measured from <code>0</code>', 'Must be even.', 'Odd-only restriction: disabled.')
85
+ end
86
+
87
+ it 'describes character length for text and neutral length for unknown types' do
88
+ expect(render(:length, { min: 0, max: 12 }, type: 'String')).to include('Minimum length: <code>0</code> characters', 'Maximum length: <code>12</code> characters')
89
+ expect(render(:length, { equals: 2 }, type: 'Custom')).to include('Length must equal <code>2</code>.')
90
+ expect(render(:length, { equals: 2 }, type: 'Array')).to include('<code>2</code> items')
91
+ expect(render(:length, { equals: 2 }, type: 'Text')).not_to include('bytes')
92
+ end
93
+
94
+ it 'shows regex source, negation and descriptions without inventing flags' do
95
+ html = render(:format, { rx: '^[a-z]+$', match: false, description: 'Reserved spelling', message: 'Invalid spelling' })
96
+ expect(html).to include('Must not match this regular expression:', '<code>^[a-z]+$</code>', 'Reserved spelling', 'Validation error message:', 'Invalid spelling')
97
+ end
98
+
99
+ it 'labels application-specific rules without claiming generic enforcement' do
100
+ expect(render(:custom, 'Verified by the application')).to include('Application-specific rule: Verified by the application')
101
+ expect(render(:custom, 'Verified by the application')).not_to include('Must pass', 'automatically')
102
+ end
103
+
104
+ it 'preserves unknown fields and unexpected shapes with a labelled lossless fallback' do
105
+ html = render('X-Extension', { 'nested' => { 'zero' => 0, 'false' => false, 'empty' => '', 'null' => nil } })
106
+ expect(html).to include('X-Extension', 'JSON:', '&quot;zero&quot;: 0', '&quot;false&quot;: false', '&quot;empty&quot;: &quot;&quot;', '&quot;null&quot;: null')
107
+ expect(render(:include, { values: 'unexpected', message: 'Kept' })).to include('Supplied validator metadata:', 'unexpected', 'Kept')
108
+ expect(render(:number, { min: 'not numeric', message: 'Kept' })).to include('Supplied validator metadata:', 'not numeric', 'Kept')
109
+ expect(render(:include, { values: [], extra: false })).to include('Additional details', CGI.escapeHTML({ extra: false }.inspect), 'Ruby value:')
110
+ expect(render(:include, { values: [:symbol] })).to include('Ruby value:', ':symbol')
111
+ end
112
+
113
+ it 'escapes source labels messages and structured options once without mutation' do
114
+ payload = '<script>&"'
115
+ options = { values: { payload => payload }, message: payload, extra: { 'nested' => payload } }
116
+ before = Marshal.dump(options)
117
+ html = render(:include, options)
118
+ expect(html).not_to include('<script>', '&amp;lt;')
119
+ expect(html).to include('&lt;script&gt;&amp;', 'Validation error message:', 'Additional details')
120
+ expect(render(payload, { 'extra' => payload })).to include('<th scope="row">&lt;script&gt;&amp;&quot;</th>')
121
+ expect(Marshal.dump(options)).to eq(before)
122
+ end
123
+ end
@@ -174,7 +174,7 @@ describe 'Documentation' do
174
174
  expect(last_response.headers['Content-Type']).to include('text/html')
175
175
 
176
176
  # servers/ruby/doc/index.md contains this heading
177
- expect(last_response.body).to include('HaveAPI documentation')
177
+ expect(last_response.body).to include('HaveAPI internals')
178
178
  end
179
179
 
180
180
  it 'has online index doc page without a sidebar template' do
@@ -182,11 +182,11 @@ describe 'Documentation' do
182
182
 
183
183
  expect(last_response.status).to eq(200)
184
184
  expect(last_response.headers['Content-Type']).to include('text/html')
185
- expect(last_response.body).to include('HaveAPI documentation')
185
+ expect(last_response.body).to include('HaveAPI internals')
186
186
  end
187
187
 
188
188
  it 'has online doc for every version' do
189
- get '/v1/'
189
+ get '/v1/?resource=user'
190
190
  expect(last_response.status).to eq(200)
191
191
  expect(last_response.headers['Content-Type']).to include('text/html')
192
192
  expect(last_response.body).to include('verified by MFA backend')
@@ -55,6 +55,8 @@ describe HaveAPI::Server do
55
55
  end
56
56
  %w[
57
57
  lib/haveapi/documentation_context.rb lib/haveapi/documentation_clients.rb
58
+ lib/haveapi/shared_documentation_helpers.rb
59
+ lib/haveapi/views/shared_sidebar.erb lib/haveapi/views/shared_documentation.erb
58
60
  lib/haveapi/views/version_page/client_result.erb
59
61
  lib/haveapi/public/js/highlight.pack.js lib/haveapi/public/js/highlight-LICENSE
60
62
  ].each do |name|
@@ -83,9 +85,9 @@ describe HaveAPI::Server do
83
85
  server.use_version(1)
84
86
  server.mount('/')
85
87
  request = Rack::MockRequest.new(server.app)
86
- responses = %w[/doc/json-schema /doc/protocol.md /doc/create-client.md /doc/client-examples.md /js/highlight-LICENSE].to_h do |path|
88
+ responses = %w[/ /doc /doc/index /doc/index.md /doc/readme /doc/json-schema /doc/protocol.md /doc/create-client.md /doc/client-examples.md /js/highlight-LICENSE].to_h do |path|
87
89
  response = request.get(path, 'HTTP_HOST' => 'localhost')
88
- [path, { status: response.status, type: response.content_type, body: response.body }]
90
+ [path, { status: response.status, type: response.content_type, cache_control: response['Cache-Control'], body: response.body }]
89
91
  end
90
92
  puts JSON.generate({
91
93
  framework: HaveAPI::Server.instance_method(:mount).source_location.first,
@@ -117,6 +119,11 @@ describe HaveAPI::Server do
117
119
  expect(File.realpath(result.fetch('framework'))).to eq(File.join(dir, 'lib/haveapi/server.rb'))
118
120
 
119
121
  {
122
+ '/' => 'API versions',
123
+ '/doc' => 'Server-side implementation',
124
+ '/doc/index' => 'Server-side implementation',
125
+ '/doc/index.md' => 'Server-side implementation',
126
+ '/doc/readme' => 'Server-side implementation',
120
127
  '/doc/json-schema' => 'Envelope',
121
128
  '/doc/protocol.md' => 'Protocol definition',
122
129
  '/doc/create-client.md' => 'Client definition',
@@ -127,6 +134,18 @@ describe HaveAPI::Server do
127
134
  expect(response.fetch('status')).to eq(200), "Packaged route failed: #{path}"
128
135
  expect(response.fetch('type')).to include('text/html') unless path == '/js/highlight-LICENSE'
129
136
  expect(response.fetch('body')).to include(heading)
137
+ next if path == '/js/highlight-LICENSE'
138
+
139
+ expect(response.fetch('cache_control')).to eq('private, no-store')
140
+ expect(response.fetch('body')).to include('data-docs', '/js/documentation.js', '/css/documentation.css')
141
+ expect(response.fetch('body')).not_to include('/js/main.js', '/css/bootstrap')
142
+ end
143
+ %w[/doc /doc/index /doc/index.md /doc/readme].each do |path|
144
+ body = result.fetch('responses').fetch(path).fetch('body')
145
+ expect(body).to include('<title>HaveAPI internals · ', 'Server framework features',
146
+ '<pre><code class="ruby">', 'class User &lt; ActiveRecord::Base')
147
+ menu = body.match(%r{<nav aria-label="Shared documentation">(.*?)</nav>}m)[1]
148
+ expect(menu).not_to include('href="/doc/readme"')
130
149
  end
131
150
  end
132
151
  end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+ require_relative '../../benchmarks/endpoints'
3
+ require_relative '../../test_support/documentation_performance_fixture'
4
+
5
+ describe HaveAPI::Performance::DocumentationScenario, '#call' do
6
+ def scenario(id, user = 'admin')
7
+ HaveAPI::Performance::DocumentationScenario.new(id:, dimensions: { size: 1, user: })
8
+ end
9
+
10
+ it 'validates a real cookie token on every HTML page with stable role-specific bodies and counters' do
11
+ page = scenario('docs.html_resources')
12
+ page.reset!(iterations: 3)
13
+ responses = 3.times.map { page.call }
14
+ responses.each_with_index { |response, index| expect(page.validate!(response, index:)[:http_status]).to eq(200) }
15
+ expect(responses.map(&:body).uniq.length).to eq(1)
16
+ expect(responses.first.body).to include('<span class="docs-identity">admin</span>', 'name="csrf"')
17
+ fixture = page.instance_variable_get(:@server).fixture_documentation_oauth
18
+ expect(fixture.lookups).to eq(Array.new(3) { [1, 'documentation-admin'] })
19
+ expect(page.documentation_identity).to eq(mode: 'documentation_oauth_cookie', role: 'admin')
20
+ expect(page.counters[:operations]).to eq(Array.new(3) { { queries: 0, authorizations: 2, executions: 0 } })
21
+ end
22
+
23
+ it 'keeps member role filtering and independent CSRF without accepting an admin overview' do
24
+ admin = scenario('docs.html_resources')
25
+ member = scenario('docs.html_resources', 'member')
26
+ [admin, member].each { |page| page.reset!(iterations: 1) }
27
+ administrative = admin.call
28
+ restricted = member.call
29
+ expect(member.validate!(restricted, index: 0)[:http_status]).to eq(200)
30
+ expect(restricted.body).to include('<span class="docs-identity">member</span>')
31
+ expect(restricted.body).not_to include('root-resource0-admin_only')
32
+ expect { member.validate_body!(administrative) }.to raise_error(HaveAPI::Performance::ContractError, /authorization/)
33
+ nonce = ->(response) { response.body.match(/name="csrf" value="([^"]+)"/)[1] }
34
+ expect(nonce.call(restricted)).not_to eq(nonce.call(administrative))
35
+ end
36
+
37
+ it 'retains ordinary Basic setup and counter behavior for documentation OPTIONS' do
38
+ page = scenario('docs.options')
39
+ page.reset!(iterations: 1)
40
+ response = page.call
41
+ expect(page.validate!(response, index: 0)[:http_status]).to eq(200)
42
+ expect(page.documentation_identity).to eq(mode: 'basic', role: 'admin')
43
+ expect(page.instance_variable_get(:@headers)).to include('HTTP_AUTHORIZATION')
44
+ expect(page.instance_variable_get(:@headers)).not_to include('HTTP_COOKIE')
45
+ expect(page.instance_variable_get(:@server)).not_to respond_to(:fixture_documentation_oauth)
46
+ end
47
+ end
@@ -0,0 +1,97 @@
1
+ require 'spec_helper'
2
+ require_relative '../../benchmarks/endpoints'
3
+ require_relative '../../benchmarks/documentation'
4
+ require_relative '../../test_support/documentation_performance_fixture'
5
+
6
+ describe HaveAPI::Performance::DocumentationScenario do
7
+ let(:scenario) { described_class.new(id: 'docs.html_resources', dimensions: { size: 2, user: 'admin' }) }
8
+ let(:overview) do
9
+ scenario.reset!(iterations: 1)
10
+ scenario.call
11
+ end
12
+ let(:error) { HaveAPI::Performance::ContractError }
13
+
14
+ def response_with(body)
15
+ Rack::MockResponse.new(200, { 'content-type' => 'text/html' }, body)
16
+ end
17
+
18
+ def replace_map(body)
19
+ body.sub(%r{(<script\b[^>]*data-legacy-anchors[^>]*>)(.*?)(</script>)}m) do
20
+ before, json, after = Regexp.last_match.captures
21
+ values = JSON.parse(json)
22
+ yield(values)
23
+ "#{before}#{JSON.generate(values)}#{after}"
24
+ end
25
+ end
26
+
27
+ it 'accepts the actual overview and preserves authorization counters and raw identity' do
28
+ identity = scenario.validate!(overview, index: 0)
29
+ expect(identity[:page]).to include(shape: 'overview', actual_examples: 0, resources: 2, actions: 3)
30
+ expect(identity[:body_sha256]).to eq(Digest::SHA256.hexdigest(overview.body))
31
+ expect(overview.body).not_to include('PUBLIC_SAMPLE_', 'ADMIN_ONLY_REQUEST')
32
+ end
33
+
34
+ it 'accepts the strict legacy full-page contract without accepting an empty third shape' do
35
+ legacy = response_with('init-python-sync init-examples PUBLIC_SAMPLE_0 ADMIN_ONLY_REQUEST')
36
+ expect(scenario.validate_body!(legacy)[:page]).to include(shape: 'monolithic')
37
+ expect { scenario.validate_body!(response_with('<h1>Empty</h1>')) }.to raise_error(error, /panels/)
38
+ end
39
+
40
+ it 'requires directory navigation, canonical targets and real initialization code' do
41
+ bodies = [
42
+ overview.body.gsub('data-resource-directory-link', 'data-missing-directory-link'),
43
+ overview.body.sub('href="/benchmark/api/v1/?page=resources"', 'href="https://foreign.example/"'),
44
+ overview.body.sub('id="init-python-sync"', 'id="missing-python-sync"'),
45
+ overview.body.sub('id="init-python-sync-thorough"', 'id="missing-python-thorough"'),
46
+ overview.body.sub('id="init-javascript-async"', 'id="missing-javascript-async"')
47
+ ]
48
+ bodies.each do |body|
49
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /navigation|initialization/)
50
+ end
51
+ end
52
+
53
+ it 'rejects duplicate, malformed, wrong and mixed overview representations' do
54
+ bodies = [
55
+ overview.body.sub('data-page="overview"', 'data-page="resource"'),
56
+ overview.body.sub('data-docs data-page=', 'data-docs data-docs data-page='),
57
+ "#{overview.body}<div data-docs data-page=\"overview\"></div>",
58
+ overview.body.sub('data-page="overview"', 'data-page="overview" data-page="resource"'),
59
+ "#{overview.body}<div id=\"example-resource0-action0-0-examples\">PUBLIC_SAMPLE_0</div>"
60
+ ]
61
+ bodies.each { |body| expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /overview/) }
62
+ end
63
+
64
+ it 'rejects missing, wrong or foreign legacy destinations and wrong authorization roles' do
65
+ maps = [
66
+ ->(values) { values.delete('root-resource1-action0') },
67
+ ->(values) { values['root-resource1'] = '/benchmark/api/v1/?resource=resource0' },
68
+ ->(values) { values['example-resource0-action0-'] = 'https://foreign.example/' },
69
+ ->(values) { values.delete('root-resource0-admin_only') },
70
+ ->(values) { values['auth-basic'] = 'https://foreign.example/' }
71
+ ]
72
+ maps.each do |mutate|
73
+ body = replace_map(overview.body, &mutate)
74
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /authorization|targets/)
75
+ end
76
+ member = described_class.new(id: 'docs.html_resources', dimensions: { size: 2, user: 'member' })
77
+ expect { member.validate_body!(overview) }.to raise_error(error, /authorization/)
78
+ end
79
+
80
+ it 'rejects malformed legacy JSON and private parameter or sample data in either role' do
81
+ body = overview.body.sub(%r{(<script[^>]*data-legacy-anchors[^>]*>).*?(</script>)}m, '\1[\2')
82
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /map/)
83
+ %w[PUBLIC_SAMPLE_0 ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE admin_field].each do |marker|
84
+ expect { scenario.validate_body!(response_with("#{overview.body}<p>#{marker}</p>")) }.to raise_error(error, /action data/)
85
+ end
86
+ end
87
+
88
+ it 'retains the optional benchmark root HTML lane for both explicit representations' do
89
+ benchmark = HaveAPI::DocumentationBenchmark.new([])
90
+ dimensions = { resource_count: 2 }
91
+ expect(benchmark.send(:html_output, overview, dimensions)[:status]).to eq(200)
92
+ legacy = response_with('init-examples PUBLIC_SAMPLE_0')
93
+ expect(benchmark.send(:html_output, legacy, dimensions)[:status]).to eq(200)
94
+ malformed = response_with(overview.body.sub('data-page="overview"', 'data-page="resource"'))
95
+ expect { benchmark.send(:html_output, malformed, dimensions) }.to raise_error(HaveAPI::DocumentationBenchmark::InvalidOutput)
96
+ end
97
+ end
@@ -81,6 +81,22 @@ RSpec.describe HaveAPI::PerformanceCI do
81
81
  expect(result[:push_pair]).to be_nil
82
82
  expect(repository).to have_received(:paths).with(base, candidate)
83
83
  end
84
+
85
+ it "selects no work for an equal new-branch merge-base with created=#{created}" do
86
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
87
+ 'before' => '0' * 40, 'created' => created)
88
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
89
+ allow(repository).to receive(:paths).with(candidate, candidate).and_return([])
90
+
91
+ expect(resolve('push', event)).to include(schema_version: 1, event_name: 'push', profile: 'quick',
92
+ baseline_sha: candidate, candidate_sha: candidate, captured_default_sha: default,
93
+ relevant: false, changed_paths: [], push_pair: nil,
94
+ reason: /candidate already contained in captured default, no source changes/)
95
+ expect(repository).to have_received(:commit!).with(default)
96
+ expect(repository).to have_received(:commit!).with(candidate)
97
+ expect(repository).to have_received(:release_lineage).with(candidate).at_least(:once)
98
+ expect(repository).to have_received(:ancestor?).with(candidate, candidate)
99
+ end
84
100
  end
85
101
 
86
102
  it 'does not guess a fallback when the captured default commit is unavailable' do
@@ -89,10 +105,58 @@ RSpec.describe HaveAPI::PerformanceCI do
89
105
  expect { resolve('push', event, default_sha: default) }.to raise_error(described_class::Error, /unavailable default/)
90
106
  end
91
107
 
92
- it 'rejects a new branch whose captured-default merge-base equals its head' do
93
- event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
108
+ it 'resolves default and ancestral branch creation without work using real Git history' do
109
+ Dir.mktmpdir('haveapi-new-branch-history-') do |directory|
110
+ git = lambda do |*arguments, input: ''|
111
+ env = { 'GIT_AUTHOR_NAME' => 'Fixture', 'GIT_AUTHOR_EMAIL' => 'fixture@example.test',
112
+ 'GIT_COMMITTER_NAME' => 'Fixture', 'GIT_COMMITTER_EMAIL' => 'fixture@example.test' }
113
+ output, error, status = Open3.capture3(env, 'git', '-C', directory, *arguments, stdin_data: input)
114
+ raise error unless status.success?
115
+
116
+ output.strip
117
+ end
118
+ git.call('init', '--quiet', '--initial-branch=master')
119
+ commit = lambda do |body, parent = nil|
120
+ blob = git.call('hash-object', '-w', '--stdin', input: body)
121
+ tree = git.call('mktree', input: "100644 blob #{blob}\tGemfile\n")
122
+ git.call('commit-tree', tree, *[('-p' if parent), parent].compact, input: "fixture\n")
123
+ end
124
+ older = commit.call('older')
125
+ captured = commit.call('default', older)
126
+ changed = commit.call('changed', captured)
127
+ git.call('update-ref', 'refs/remotes/origin/master', captured)
128
+ stub_const('HaveAPI::Performance::REFERENCE', older)
129
+ real_resolver = described_class::Resolver.new(described_class::Repository.new(directory))
130
+ event.merge!('ref' => 'refs/heads/haveapi-0.32', 'before' => '0' * 40, 'created' => true)
131
+
132
+ [captured, older, changed].each do |head|
133
+ result = real_resolver.resolve(event_name: 'push', event: event.merge('after' => head), event_sha: head)
134
+ expect(result).to include(candidate_sha: head, captured_default_sha: captured, release_lineage: nil, push_pair: nil,
135
+ baseline_sha: head == changed ? captured : head,
136
+ relevant: head == changed, changed_paths: head == changed ? ['Gemfile'] : [])
137
+ end
138
+ end
139
+ end
140
+
141
+ it 'retains lineage and ancestry rejection for an equal new-branch merge-base' do
142
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'after' => candidate, 'before' => '0' * 40)
143
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
144
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
145
+ event['ref'] = 'refs/heads/feature'
146
+ allow(repository).to receive(:ancestor?).with(candidate, candidate).and_return(false)
147
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /ancestor/)
148
+ end
149
+
150
+ it 'rejects equal endpoints for PRs and fixed-reference profiles' do
151
+ event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
94
152
  allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
153
+ expect { resolve('pull_request', event) }.to raise_error(described_class::Error, /differ/)
154
+ event.merge!('ref' => 'refs/heads/feature', 'after' => described_class::REFERENCE, 'before' => '0' * 40,
155
+ 'inputs' => { 'profile' => 'weekly' })
156
+ allow(repository).to receive(:merge_base).with(described_class::REFERENCE, default).and_return(described_class::REFERENCE)
95
157
  expect { resolve('push', event) }.to raise_error(described_class::Error, /differ/)
158
+ expect { resolver.resolve(event_name: 'schedule', event:, event_sha: described_class::REFERENCE) }
159
+ .to raise_error(described_class::Error, /differ/)
96
160
  end
97
161
 
98
162
  it 'does not replace an unavailable new-branch merge-base with another commit' do
@@ -323,6 +387,37 @@ RSpec.describe HaveAPI::PerformanceCI do
323
387
  end
324
388
  end
325
389
 
390
+ it 'records no-work without requiring benchmark artifacts or the client contract' do
391
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
392
+ steps = YAML.load_file(path).fetch('jobs').fetch('performance').fetch('steps')
393
+ comparison = steps.find { |step| step['name'] == 'Run the selected pair outside Bundler' }
394
+ expect(comparison.fetch('if')).to eq("steps.resolve.outputs.relevant == 'true'")
395
+ completion = steps.find { |step| step['id'] == 'completion' }
396
+ Dir.mktmpdir('haveapi-no-work-gate-') do |directory|
397
+ File.write(File.join(directory, 'selection.json'), JSON.generate(relevant: false))
398
+ output = File.join(directory, 'output')
399
+ environment = { 'RELEVANT' => 'false', 'CLIENT_CONTRACT' => '',
400
+ 'PERFORMANCE_EVIDENCE' => directory, 'GITHUB_OUTPUT' => output }
401
+ _output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
402
+ expect(status.success?).to be(true)
403
+ expect(File.read(output)).to eq("outcome=no-work\n")
404
+ File.unlink(File.join(directory, 'selection.json'))
405
+ _output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
406
+ expect(status.success?).to be(false)
407
+ end
408
+ end
409
+
410
+ it 'keeps equal endpoints out of the campaign even if marked relevant' do
411
+ Dir.mktmpdir('haveapi-no-work-campaign-') do |directory|
412
+ selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick', 'event_name' => 'push',
413
+ 'candidate_sha' => candidate, 'baseline_sha' => candidate, 'push_pair' => nil }
414
+ campaign = described_class::Campaign.new(repository:, selection:,
415
+ evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
416
+ expect { campaign.run }.to raise_error(described_class::Error, /ancestry/)
417
+ expect(File).not_to exist(File.join(directory, 'sources'))
418
+ end
419
+ end
420
+
326
421
  it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
327
422
  path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
328
423
  script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
@@ -29,6 +29,83 @@ describe HaveAPI::Performance do
29
29
  end
30
30
  end
31
31
 
32
+ def with_console_versions(pinned: true)
33
+ with_newer_json do |directory, environment|
34
+ (pinned ? %w[0.9.4 99.0.0] : ['99.0.0']).each do |version|
35
+ library = File.join(directory, "gems/io-console-#{version}/lib/io")
36
+ FileUtils.mkdir_p(library)
37
+ File.write(File.join(library, 'console.rb'), "CONSOLE_ACTIVATION_CONTROL = #{version.inspect}\n")
38
+ spec = Gem::Specification.new do |gem|
39
+ gem.name = 'io-console'
40
+ gem.version = version
41
+ gem.summary = 'Owned worker activation control'
42
+ gem.authors = ['Fixture']
43
+ end
44
+ File.write(File.join(directory, "specifications/io-console-#{version}.gemspec"), spec.to_ruby)
45
+ end
46
+ yield directory, environment
47
+ end
48
+ end
49
+
50
+ it 'activates the exact frozen console before source loading despite a newer visible version' do
51
+ with_console_versions do |_directory, environment|
52
+ program = <<~RUBY
53
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
54
+ visible = Gem::Specification.find_all_by_name('io-console').map { |spec| spec.version.to_s }
55
+ begin
56
+ HaveAPI::Performance::Worker.new('benchmark' => true, 'source_root' => '/missing-worker-source').run
57
+ rescue Errno::ENOENT
58
+ puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('io-console').version.to_s,
59
+ required: CONSOLE_ACTIVATION_CONTROL, pp_loaded: $LOADED_FEATURES.any? { |path| path.end_with?('/pp.rb') })
60
+ end
61
+ RUBY
62
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
63
+ expect(status).to be_success, errors
64
+ result = JSON.parse(output)
65
+ expect(result.fetch('visible')).to include('99.0.0')
66
+ expect(result).to include('loaded' => '0.9.4', 'required' => '0.9.4', 'pp_loaded' => false)
67
+ budgets = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
68
+ expect(budgets.fetch('environment').values.map { |value| value.fetch('gems').fetch('io-console').fetch('version') })
69
+ .to all(eq(result.fetch('loaded')))
70
+ lock = Bundler::LockfileParser.new(File.read(File.join(root, 'servers/ruby/benchmarks/Gemfile.lock')))
71
+ expect(lock.specs.find { |spec| spec.name == 'io-console' }.version.to_s).to eq(result.fetch('loaded'))
72
+ end
73
+ end
74
+
75
+ it 'leaves ordinary and non-Boolean worker console activation unchanged' do
76
+ with_console_versions(pinned: false) do |_directory, environment|
77
+ program = <<~RUBY
78
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
79
+ before = Gem.loaded_specs['io-console']&.version&.to_s
80
+ [nil, false, 'true', 1].each do |flag|
81
+ HaveAPI::Performance::Worker.new('benchmark' => flag).send(:activate_benchmark_console)
82
+ end
83
+ puts JSON.generate(before: before, after: Gem.loaded_specs['io-console']&.version&.to_s,
84
+ required: defined?(CONSOLE_ACTIVATION_CONTROL))
85
+ RUBY
86
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
87
+ expect(status).to be_success, errors
88
+ expect(JSON.parse(output)).to include('before' => nil, 'after' => nil, 'required' => nil)
89
+ end
90
+ end
91
+
92
+ it 'fails frozen workers clearly when the console version is missing or already conflicting' do
93
+ %w[missing conflicting].each do |control|
94
+ with_console_versions(pinned: control == 'conflicting') do |directory, environment|
95
+ program = <<~RUBY
96
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
97
+ Gem.use_paths(#{directory.inspect}, [#{directory.inspect}])
98
+ gem 'io-console', '= 99.0.0' if #{control.inspect} == 'conflicting'
99
+ HaveAPI::Performance::Worker.new('benchmark' => true).send(:activate_benchmark_console)
100
+ RUBY
101
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
102
+ expect(status).not_to be_success
103
+ expect(errors).to include('io-console', '0.9.4')
104
+ expect(errors).to include(control == 'missing' ? 'Could not find' : 'already activated io-console-99.0.0')
105
+ end
106
+ end
107
+ end
108
+
32
109
  it 'loads the native calibrated version in each public entry despite a newer visible gem' do
33
110
  with_newer_json do |_directory, environment|
34
111
  [comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
@@ -76,7 +153,7 @@ describe HaveAPI::Performance do
76
153
  _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
77
154
  expect(status).not_to be_success
78
155
  expect(errors).to include('json', '2.21.2', '3.1.0')
79
- expect(errors).to match(/Could not find/)
156
+ expect(errors).to include('Could not find')
80
157
  end
81
158
  end
82
159
 
@@ -236,6 +236,7 @@ describe HaveAPI::Performance::Release do
236
236
  File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
237
237
  FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
238
238
  File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
239
+ stub_const('HaveAPI::RustClient::VERSION', HaveAPI::VERSION) unless defined?(HaveAPI::RustClient::VERSION)
239
240
  stub_const('HaveAPI::Performance::ROOT', harness)
240
241
  ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
241
242
  preparation = ordinary.fetch('json_preparation')
@@ -278,9 +279,9 @@ describe HaveAPI::Performance::Release do
278
279
  end
279
280
 
280
281
  def served_html
281
- server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1)
282
- response = Rack::MockRequest.new(server.app).get('http://example.test/benchmark/api/v1/',
283
- 'HTTP_HOST' => 'example.test', 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64('admin:fixture-only')}")
282
+ server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1, documentation_oauth: true)
283
+ response = Rack::MockRequest.new(server.app).get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/",
284
+ 'HTTP_HOST' => 'example.test', 'HTTP_COOKIE' => server.fixture_documentation_cookie('admin'))
284
285
  expect(response.status).to eq(200)
285
286
  response.body
286
287
  end
@@ -467,6 +467,7 @@ describe HaveAPI::Performance::Worker do
467
467
  expect(retained['cases'].map { |item| item['iterations'] }).to all(eq(expensive ? 1 : 4))
468
468
  expect(retained['cases'].map { |item| item['pilot']['baseline_batch_seconds'] }).to all(eq(expensive ? 2.0 : 1.0))
469
469
  requests.each do |label, request|
470
+ expect(request['benchmark']).to be(true)
470
471
  next if %w[pilot calibration].include?(label)
471
472
 
472
473
  expect(request['counts'].values).to all(eq(expensive ? 1 : 4))