ruact 0.0.2 → 0.0.4

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — `Ruact::ServerFunctions::StandaloneDispatcher`: AC2 + AC3
4
+ # dispatch shape: JSON / multipart / URL-encoded bodies; nil → 204; Hash →
5
+ # 200 JSON; Array → 200 JSON; `Ruact::ActionError` → status + body; unknown
6
+ # content-type → empty params; params shadow is `ActionController::Parameters`.
7
+
8
+ require "spec_helper"
9
+ require "action_controller"
10
+ require "action_dispatch"
11
+ require "rack"
12
+ require "securerandom"
13
+
14
+ module Ruact
15
+ module ServerFunctions
16
+ RSpec.describe StandaloneDispatcher, :story_8_3 do
17
+ let(:posts_module) do
18
+ Module.new do
19
+ extend Ruact::ServerAction
20
+
21
+ def self.name
22
+ "StandaloneDispatcherHost"
23
+ end
24
+ end
25
+ end
26
+
27
+ def make_request(body:, content_type:)
28
+ env = Rack::MockRequest.env_for(
29
+ "/__ruact/fn/whatever",
30
+ method: "POST",
31
+ input: body,
32
+ "CONTENT_TYPE" => content_type
33
+ )
34
+ ActionDispatch::Request.new(env)
35
+ end
36
+
37
+ def make_response
38
+ ActionDispatch::Response.new.tap do |resp|
39
+ # Touch the response so its internal state is initialized.
40
+ resp.request = nil
41
+ end
42
+ end
43
+
44
+ def register_and_entry(symbol, &block)
45
+ posts_module.module_eval { ruact_action(symbol, &block) }
46
+ Ruact.action_registry.entries[symbol]
47
+ end
48
+
49
+ describe "AC2 — content-type routing into params shadow" do
50
+ it "parses application/json bodies" do
51
+ entry = register_and_entry(:json_echo, &:to_unsafe_h)
52
+ request = make_request(body: '{"title":"Hi"}', content_type: "application/json")
53
+ response = make_response
54
+
55
+ described_class.dispatch(entry, request, response)
56
+
57
+ expect(response.status).to eq(200)
58
+ expect(response.headers["Content-Type"]).to include("application/json")
59
+ expect(JSON.parse(response.body)).to eq("title" => "Hi")
60
+ end
61
+
62
+ it "parses multipart/form-data bodies (Story 8.3 review R5 — AC9 multipart coverage)" do
63
+ # Hand-build a multipart body so the dispatcher exercises the same
64
+ # `request.request_parameters` path the runtime's `<form action>`
65
+ # wire shape produces. Mirrors the `multipart_post` helper in
66
+ # dispatch_request_spec.rb.
67
+ boundary = "----RuactDispatcherSpec#{SecureRandom.hex(8)}"
68
+ body = +""
69
+ body << "--#{boundary}\r\n"
70
+ body << "Content-Disposition: form-data; name=\"title\"\r\n\r\n"
71
+ body << "From multipart\r\n"
72
+ body << "--#{boundary}\r\n"
73
+ body << "Content-Disposition: form-data; name=\"body\"\r\n\r\n"
74
+ body << "Multipart body\r\n"
75
+ body << "--#{boundary}--\r\n"
76
+
77
+ entry = register_and_entry(:multipart_echo, &:to_unsafe_h)
78
+ request = make_request(body: body, content_type: "multipart/form-data; boundary=#{boundary}")
79
+ response = make_response
80
+
81
+ described_class.dispatch(entry, request, response)
82
+
83
+ expect(response.status).to eq(200)
84
+ expect(JSON.parse(response.body)).to eq(
85
+ "title" => "From multipart",
86
+ "body" => "Multipart body"
87
+ )
88
+ end
89
+
90
+ it "parses application/x-www-form-urlencoded bodies" do
91
+ entry = register_and_entry(:form_echo, &:to_unsafe_h)
92
+ request = make_request(
93
+ body: "title=Hello&body=World",
94
+ content_type: "application/x-www-form-urlencoded"
95
+ )
96
+ response = make_response
97
+
98
+ described_class.dispatch(entry, request, response)
99
+
100
+ expect(response.status).to eq(200)
101
+ expect(JSON.parse(response.body)).to eq("title" => "Hello", "body" => "World")
102
+ end
103
+
104
+ it "returns an empty params hash for unknown content types" do
105
+ entry = register_and_entry(:unknown_ct) { |params| { "keys" => params.to_unsafe_h.keys } }
106
+ request = make_request(body: "ignored", content_type: "application/xml")
107
+ response = make_response
108
+
109
+ described_class.dispatch(entry, request, response)
110
+
111
+ expect(response.status).to eq(200)
112
+ expect(JSON.parse(response.body)).to eq("keys" => [])
113
+ end
114
+
115
+ it "treats an empty JSON body as an empty hash" do
116
+ entry = register_and_entry(:empty_json, &:to_unsafe_h)
117
+ request = make_request(body: "", content_type: "application/json")
118
+ response = make_response
119
+
120
+ described_class.dispatch(entry, request, response)
121
+
122
+ expect(response.status).to eq(200)
123
+ expect(JSON.parse(response.body)).to eq({})
124
+ end
125
+
126
+ it "wraps a scalar JSON top-level value under the `_value` key (mirrors controller path)" do
127
+ entry = register_and_entry(:scalar_json) { |params| { value: params[:_value] } }
128
+ request = make_request(body: "42", content_type: "application/json")
129
+ response = make_response
130
+
131
+ described_class.dispatch(entry, request, response)
132
+
133
+ expect(response.status).to eq(200)
134
+ expect(JSON.parse(response.body)).to eq("value" => 42)
135
+ end
136
+
137
+ it "exposes the params shadow as ActionController::Parameters (strong params API)" do
138
+ entry = register_and_entry(:strong) do |params|
139
+ permitted = params.require(:post).permit(:title)
140
+ { "permitted" => permitted.to_h }
141
+ end
142
+ request = make_request(
143
+ body: '{"post":{"title":"Hi","evil":"ignored"}}',
144
+ content_type: "application/json"
145
+ )
146
+ response = make_response
147
+
148
+ described_class.dispatch(entry, request, response)
149
+
150
+ expect(response.status).to eq(200)
151
+ expect(JSON.parse(response.body)).to eq("permitted" => { "title" => "Hi" })
152
+ end
153
+ end
154
+
155
+ describe "AC2 — response shape" do
156
+ it "renders 204 No Content when the block returns nil" do
157
+ entry = register_and_entry(:nil_return) { |_params| nil }
158
+ request = make_request(body: "{}", content_type: "application/json")
159
+ response = make_response
160
+
161
+ described_class.dispatch(entry, request, response)
162
+
163
+ expect(response.status).to eq(204)
164
+ expect(response.body.to_s).to eq("")
165
+ end
166
+
167
+ it "renders 200 + JSON when the block returns a Hash" do
168
+ entry = register_and_entry(:hash_return) { |_params| { ok: true } }
169
+ request = make_request(body: "{}", content_type: "application/json")
170
+ response = make_response
171
+
172
+ described_class.dispatch(entry, request, response)
173
+
174
+ expect(response.status).to eq(200)
175
+ expect(JSON.parse(response.body)).to eq("ok" => true)
176
+ end
177
+
178
+ it "renders 200 + JSON when the block returns an Array" do
179
+ entry = register_and_entry(:array_return) { |_params| [1, 2, 3] }
180
+ request = make_request(body: "{}", content_type: "application/json")
181
+ response = make_response
182
+
183
+ described_class.dispatch(entry, request, response)
184
+
185
+ expect(response.status).to eq(200)
186
+ expect(JSON.parse(response.body)).to eq([1, 2, 3])
187
+ end
188
+
189
+ it "renders 200 + JSON when the block returns a scalar" do
190
+ entry = register_and_entry(:string_return) { |_params| "pong" }
191
+ request = make_request(body: "{}", content_type: "application/json")
192
+ response = make_response
193
+
194
+ described_class.dispatch(entry, request, response)
195
+
196
+ expect(response.status).to eq(200)
197
+ expect(JSON.parse(response.body)).to eq("pong")
198
+ end
199
+ end
200
+
201
+ describe "Story 8.3 review R3 — malformed JSON → structured 400" do
202
+ it "renders 400 + JSON {error} when the JSON body is malformed " \
203
+ "(parity with the controller-DSL path's malformed-JSON handler)" do
204
+ entry = register_and_entry(:malformed_demo) { |_p| { ok: true } }
205
+ request = make_request(body: "{ not json", content_type: "application/json")
206
+ response = make_response
207
+
208
+ described_class.dispatch(entry, request, response)
209
+
210
+ expect(response.status).to eq(400)
211
+ body = JSON.parse(response.body)
212
+ expect(body.fetch("error")).to match(/ruact action :malformed_demo received malformed JSON body/)
213
+ end
214
+
215
+ it "does NOT invoke the block when the body cannot be parsed" do
216
+ block_called = false
217
+ entry = register_and_entry(:never_runs) do |_p|
218
+ block_called = true
219
+ { ok: true }
220
+ end
221
+ request = make_request(body: "{ broken", content_type: "application/json")
222
+ response = make_response
223
+
224
+ described_class.dispatch(entry, request, response)
225
+
226
+ expect(block_called).to be(false)
227
+ expect(response.status).to eq(400)
228
+ end
229
+ end
230
+
231
+ describe "AC2 — Ruact::ActionError → status + body" do
232
+ it "renders the error's integer status + JSON body verbatim" do
233
+ entry = register_and_entry(:raise_action_error) do |_params|
234
+ raise Ruact::ActionError.new(status: 422, body: { error: "invalid" })
235
+ end
236
+ request = make_request(body: "{}", content_type: "application/json")
237
+ response = make_response
238
+
239
+ described_class.dispatch(entry, request, response)
240
+
241
+ expect(response.status).to eq(422)
242
+ expect(JSON.parse(response.body)).to eq("error" => "invalid")
243
+ end
244
+
245
+ it "translates a Symbol status to the matching HTTP code" do
246
+ entry = register_and_entry(:raise_symbol_status) do |_params|
247
+ raise Ruact::ActionError.new(status: :unauthorized, body: { error: "no" })
248
+ end
249
+ request = make_request(body: "{}", content_type: "application/json")
250
+ response = make_response
251
+
252
+ described_class.dispatch(entry, request, response)
253
+
254
+ expect(response.status).to eq(401)
255
+ expect(JSON.parse(response.body)).to eq("error" => "no")
256
+ end
257
+
258
+ it "renders nil body as empty when ActionError.body is nil" do
259
+ entry = register_and_entry(:raise_no_body) do |_params|
260
+ raise Ruact::ActionError.new(status: 418, body: nil)
261
+ end
262
+ request = make_request(body: "{}", content_type: "application/json")
263
+ response = make_response
264
+
265
+ described_class.dispatch(entry, request, response)
266
+
267
+ expect(response.status).to eq(418)
268
+ expect(response.body.to_s).to eq("")
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.1 — request-cycle spec for the Story 8.4 structured-error chain
4
+ # RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
5
+ # `server_functions/endpoint_controller_rescue_spec.rb` (removed in the same
6
+ # commit — AC5: no orphan salvage, no double coverage). Pins, against REAL
7
+ # host-controller routes (no `/__ruact/fn/` anywhere):
8
+ #
9
+ # - structured payload on function-call requests: discriminator, baseline
10
+ # fields, dev extras, validation_errors, suggestion (inventory A1, A2,
11
+ # A5, A6, A9)
12
+ # - host `rescue_from` precedence (A12)
13
+ # - production-mode reduction to the four baseline keys (A8)
14
+ # - strict-boolean `dev_error_payload_enabled` handling — review F3 (A10)
15
+ # - server-side logging always fires (A11)
16
+ # - CSRF failure → 403 + structured body with the CSRF suggestion (A13)
17
+ # - AC1 byte-for-byte: GET page actions render untouched (C2); a raise on
18
+ # a NON-function-call request propagates to Rails' default handling —
19
+ # no structured JSON swallow (C3 / D1)
20
+ #
21
+ # Mounts on the shared Story-7.9 Rails app (`controller_request_spec.rb`).
22
+ # Deliberately does NOT depend on `server_functions/dispatch_request_spec.rb`
23
+ # — that file pins the v1 endpoint and is demolished in Story 9.9; this file
24
+ # must survive it.
25
+
26
+ require "action_controller/railtie"
27
+ require "action_view/railtie"
28
+
29
+ require "spec_helper"
30
+ require "rack/test"
31
+
32
+ require "ruact/server"
33
+
34
+ require "active_model"
35
+ require "active_record"
36
+ require "i18n"
37
+
38
+ # Locale loader (same pattern as the v1 request specs) so
39
+ # RecordInvalid#message resolves cleanly.
40
+ {
41
+ "activemodel" => "active_model",
42
+ "activerecord" => "active_record"
43
+ }.each do |gem_name, dir|
44
+ spec = Gem.loaded_specs[gem_name]
45
+ next unless spec
46
+
47
+ locale_file = File.join(spec.gem_dir, "lib", dir, "locale", "en.yml")
48
+ I18n.load_path << locale_file if File.exist?(locale_file)
49
+ end
50
+ I18n.backend.load_translations
51
+
52
+ # Constant-gated load of the shared Rails app — RSpec loads spec files via
53
+ # `Kernel.load` (no $LOADED_FEATURES dedupe), so an unconditional
54
+ # require_relative would re-run controller_request_spec.rb's top-level
55
+ # routes.append and crash with "Invalid route name, already in use".
56
+ require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
57
+
58
+ module ServerRescueSpecSupport
59
+ # The exact wire shape the 8.1 runtime sends on every `_makeRef` fetch:
60
+ # JSON body + `Accept: application/json` — the Bucket-2 / function-call
61
+ # request shape the concern's predicate keys on.
62
+ FUNCTION_CALL_HEADERS = {
63
+ "CONTENT_TYPE" => "application/json",
64
+ "HTTP_ACCEPT" => "application/json"
65
+ }.freeze
66
+
67
+ # Lightweight ActiveModel-shaped class so we can construct a REAL
68
+ # `ActiveRecord::RecordInvalid` instance.
69
+ class RescuePost
70
+ include ActiveModel::Model
71
+
72
+ attr_accessor :title
73
+
74
+ validates :title, presence: true
75
+
76
+ def self.i18n_scope
77
+ :activerecord
78
+ end
79
+ end
80
+
81
+ # Host with NO rescue_from of its own — exceptions reach the concern's
82
+ # salvaged `rescue_from StandardError`. Includes ONLY Ruact::Server: the
83
+ # concern must work standalone (Bucket-1 rendering comes from the host's
84
+ # separate Ruact::Controller include in real apps; coupling them is 9.2's
85
+ # design space).
86
+ class BareServerController < ActionController::Base
87
+ include Ruact::Server
88
+
89
+ def record_invalid
90
+ record = RescuePost.new
91
+ record.valid? # populates record.errors
92
+ raise ActiveRecord::RecordInvalid, record
93
+ end
94
+
95
+ def argument_error
96
+ raise ArgumentError, "bad arg"
97
+ end
98
+
99
+ def runtime_error
100
+ raise "boom"
101
+ end
102
+
103
+ def create_ok
104
+ render json: { "ok" => true }
105
+ end
106
+
107
+ def page
108
+ render plain: "plain page body"
109
+ end
110
+
111
+ def erroring_page
112
+ raise "boom on GET"
113
+ end
114
+
115
+ # Review patch (2026-06-08) — a host action that manually raises
116
+ # UploadTooLargeError on a GET. The structured-413 exception to the
117
+ # re-raise rule must NOT fire here: GET/HEAD always keep stock Rails
118
+ # behavior (the guard itself never produces this on a GET — D2 — so a
119
+ # structured 413 here could only come from a manual raise).
120
+ def upload_error_on_get
121
+ raise Ruact::UploadTooLargeError.new(received_bytes: 10, limit_bytes: 5)
122
+ end
123
+
124
+ # Review patch (2026-06-08, round 3) — a host action that raises
125
+ # Ruact::ConfigurationError on a function-call request. Configuration
126
+ # invariants must stay LOUD setup failures: the structured-error renderer
127
+ # must re-raise instead of folding the failure into an ordinary
128
+ # `_ruact_server_action_error` 500.
129
+ def config_error
130
+ raise Ruact::ConfigurationError, "config invariant violated"
131
+ end
132
+ end
133
+
134
+ # Parent WITHOUT the concern that catches RecordInvalid; the child includes
135
+ # `Ruact::Server`. Proves the concern's chain does not preempt handlers the
136
+ # host INHERITED either (review patch — `rescue_handlers` walks
137
+ # most-recently-registered first, and a naive include lands the concern's
138
+ # entries after the parent's).
139
+ class ParentRescuingController < ActionController::Base
140
+ rescue_from ActiveRecord::RecordInvalid do |error|
141
+ render(
142
+ json: { caught_by_parent: true, error_class: error.class.name },
143
+ status: :unprocessable_entity
144
+ )
145
+ end
146
+ end
147
+
148
+ class InheritedCaughtServerController < ParentRescuingController
149
+ include Ruact::Server
150
+
151
+ def record_invalid
152
+ record = RescuePost.new
153
+ record.valid?
154
+ raise ActiveRecord::RecordInvalid, record
155
+ end
156
+ end
157
+
158
+ # Host that catches RecordInvalid itself — proves the concern's chain does
159
+ # NOT preempt a host's own rescue_from (inventory A12).
160
+ class CaughtServerController < ActionController::Base
161
+ include Ruact::Server
162
+
163
+ rescue_from ActiveRecord::RecordInvalid do |error|
164
+ render(
165
+ json: { caught_by_host: true, error_class: error.class.name },
166
+ status: :unprocessable_entity
167
+ )
168
+ end
169
+
170
+ def record_invalid
171
+ record = RescuePost.new
172
+ record.valid?
173
+ raise ActiveRecord::RecordInvalid, record
174
+ end
175
+ end
176
+
177
+ # Host with real CSRF enforcement — the concern's explicit
178
+ # InvalidAuthenticityToken registration must render the structured 403
179
+ # for function-call requests (inventory A13). Forgery is flipped on
180
+ # per-example via `allow_forgery_protection` (class-level), mirroring the
181
+ # v1 spec pattern — but on the HOST controller now, not EndpointController.
182
+ class ForgeryServerController < ActionController::Base
183
+ include Ruact::Server
184
+
185
+ protect_from_forgery with: :exception
186
+
187
+ def create_protected
188
+ render json: { "ok" => true }
189
+ end
190
+ end
191
+ end
192
+
193
+ if defined?(ControllerRequestSpecSupport) &&
194
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_rescue_routes_appended)
195
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_rescue_routes_appended, true)
196
+ ControllerRequestSpecSupport.app_class.routes.append do
197
+ get "/server_rescue/page", to: "server_rescue_spec_support/bare_server#page"
198
+ get "/server_rescue/erroring_page", to: "server_rescue_spec_support/bare_server#erroring_page"
199
+ get "/server_rescue/upload_error_on_get", to: "server_rescue_spec_support/bare_server#upload_error_on_get"
200
+ post "/server_rescue/record_invalid", to: "server_rescue_spec_support/bare_server#record_invalid"
201
+ post "/server_rescue/argument_error", to: "server_rescue_spec_support/bare_server#argument_error"
202
+ post "/server_rescue/runtime_error", to: "server_rescue_spec_support/bare_server#runtime_error"
203
+ post "/server_rescue/create_ok", to: "server_rescue_spec_support/bare_server#create_ok"
204
+ post "/server_rescue/config_error", to: "server_rescue_spec_support/bare_server#config_error"
205
+ post "/server_rescue/caught_record_invalid", to: "server_rescue_spec_support/caught_server#record_invalid"
206
+ post "/server_rescue/protected", to: "server_rescue_spec_support/forgery_server#create_protected"
207
+ post "/server_rescue/inherited_caught_record_invalid",
208
+ to: "server_rescue_spec_support/inherited_caught_server#record_invalid"
209
+ end
210
+ end
211
+
212
+ RSpec.describe "Story 9.1: Ruact::Server concern — salvaged rescue_from chain", :story_9_1 do
213
+ include Rack::Test::Methods
214
+
215
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
216
+ let(:app) { app_class.instance }
217
+
218
+ let(:function_call_headers) { ServerRescueSpecSupport::FUNCTION_CALL_HEADERS }
219
+
220
+ before do
221
+ Rails.logger = Logger.new(IO::NULL)
222
+ ControllerRequestSpecSupport.boot!
223
+ end
224
+
225
+ describe "AC3 — function-call request: structured payload, wire contract preserved" do
226
+ it "RecordInvalid on a bare host renders 422 + the full dev-mode payload (A1/A2/A5/A6/A9)",
227
+ :aggregate_failures do
228
+ post "/server_rescue/record_invalid", "{}", function_call_headers
229
+ expect(last_response.status).to eq(422)
230
+ body = JSON.parse(last_response.body)
231
+ expect(body).to include(
232
+ "_ruact_server_action_error" => true,
233
+ "action_name" => "record_invalid",
234
+ "error_class" => "ActiveRecord::RecordInvalid"
235
+ )
236
+ expect(body.fetch("message")).to match(/Title can't be blank/)
237
+ expect(body.fetch("validation_errors")).to include(/Title can't be blank/)
238
+ expect(body.fetch("suggestion")).to eq("Validation failed — check the model's `validates` rules")
239
+ expect(body).to have_key("app_frames")
240
+ expect(body).to have_key("gem_frames")
241
+ end
242
+
243
+ it "ArgumentError renders 500 + structured payload with null suggestion (A9)", :aggregate_failures do
244
+ post "/server_rescue/argument_error", "{}", function_call_headers
245
+ expect(last_response.status).to eq(500)
246
+ body = JSON.parse(last_response.body)
247
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
248
+ expect(body.fetch("action_name")).to eq("argument_error")
249
+ expect(body.fetch("error_class")).to eq("ArgumentError")
250
+ expect(body.fetch("message")).to eq("bad arg")
251
+ expect(body.fetch("suggestion")).to be_nil
252
+ end
253
+
254
+ it "RuntimeError renders 500 + structured payload (A9)" do
255
+ post "/server_rescue/runtime_error", "{}", function_call_headers
256
+ expect(last_response.status).to eq(500)
257
+ body = JSON.parse(last_response.body)
258
+ expect(body.fetch("error_class")).to eq("RuntimeError")
259
+ expect(body.fetch("message")).to eq("boom")
260
+ end
261
+
262
+ it "a non-raising action is untouched by the chain (happy path sanity)" do
263
+ post "/server_rescue/create_ok", "{}", function_call_headers
264
+ expect(last_response.status).to eq(200)
265
+ expect(JSON.parse(last_response.body)).to eq("ok" => true)
266
+ end
267
+ end
268
+
269
+ describe "host rescue_from precedence — host wins (A12)" do
270
+ it "RecordInvalid handled by the host renders the host's body, NOT the structured payload" do
271
+ post "/server_rescue/caught_record_invalid", "{}", function_call_headers
272
+ expect(last_response.status).to eq(422)
273
+ body = JSON.parse(last_response.body)
274
+ expect(body.fetch("caught_by_host")).to be(true)
275
+ expect(body).not_to have_key("_ruact_server_action_error")
276
+ end
277
+
278
+ it "RecordInvalid handled by an INHERITED parent handler also wins over the concern (review patch)" do
279
+ post "/server_rescue/inherited_caught_record_invalid", "{}", function_call_headers
280
+ expect(last_response.status).to eq(422)
281
+ body = JSON.parse(last_response.body)
282
+ expect(body.fetch("caught_by_parent")).to be(true)
283
+ expect(body).not_to have_key("_ruact_server_action_error")
284
+ end
285
+ end
286
+
287
+ describe "production-mode payload reduction (A8)" do
288
+ before { Ruact.configure { |c| c.dev_error_payload_enabled = false } }
289
+
290
+ it "exposes only the four baseline keys on the wire" do
291
+ post "/server_rescue/record_invalid", "{}", function_call_headers
292
+ expect(last_response.status).to eq(422)
293
+ body = JSON.parse(last_response.body)
294
+ expect(body.keys).to contain_exactly(
295
+ "_ruact_server_action_error",
296
+ "action_name",
297
+ "error_class",
298
+ "message"
299
+ )
300
+ end
301
+ end
302
+
303
+ describe "strict-boolean handling for dev_error_payload_enabled — review F3 (A10)" do
304
+ it "non-boolean truthy values fall back to the env default (test env → dev mode)" do
305
+ Ruact.configure { |c| c.dev_error_payload_enabled = "false" }
306
+ post "/server_rescue/record_invalid", "{}", function_call_headers
307
+ body = JSON.parse(last_response.body)
308
+ expect(body).to have_key("app_frames")
309
+ expect(body).to have_key("gem_frames")
310
+ expect(body).to have_key("suggestion")
311
+ end
312
+
313
+ it "non-boolean falsy values fall back to the env default (test env → dev mode)" do
314
+ Ruact.configure { |c| c.dev_error_payload_enabled = 0 }
315
+ post "/server_rescue/record_invalid", "{}", function_call_headers
316
+ expect(JSON.parse(last_response.body)).to have_key("app_frames")
317
+ end
318
+ end
319
+
320
+ describe "server-side logging always fires (A11)" do
321
+ it "logs the [ruact] error line + backtrace at error severity regardless of wire mode" do
322
+ log_io = StringIO.new
323
+ Rails.logger = Logger.new(log_io)
324
+ post "/server_rescue/runtime_error", "{}", function_call_headers
325
+ expect(last_response.status).to eq(500)
326
+ expect(log_io.string).to include(
327
+ "[ruact] server action :runtime_error failed — RuntimeError: boom"
328
+ )
329
+ expect(log_io.string).to include("server_rescue_request_spec") # a backtrace frame
330
+ end
331
+ end
332
+
333
+ describe "CSRF mismatch on a function-call request → 403 + structured payload (A13 / Pitfall #1)" do
334
+ around do |example|
335
+ previous = ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection
336
+ ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection = true
337
+ example.run
338
+ ensure
339
+ ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection = previous
340
+ end
341
+
342
+ it "missing X-CSRF-Token produces the structured 403 body with the CSRF suggestion" do
343
+ post "/server_rescue/protected", "{}", function_call_headers
344
+ expect(last_response.status).to eq(403)
345
+ body = JSON.parse(last_response.body)
346
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
347
+ expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
348
+ expect(body.fetch("suggestion")).to eq(
349
+ "CSRF token mismatch — ensure the page was rendered after the most recent server restart " \
350
+ "and the session cookie is intact"
351
+ )
352
+ end
353
+ end
354
+
355
+ describe "AC1 — byte-for-byte: non-function-call requests are untouched (C2/C3/D1)" do
356
+ it "a GET page action renders exactly as without the concern (C2)" do
357
+ get "/server_rescue/page"
358
+ expect(last_response.status).to eq(200)
359
+ expect(last_response.body).to eq("plain page body")
360
+ expect(last_response.headers["Content-Type"]).to include("text/plain")
361
+ end
362
+
363
+ it "a raise on a POST without the function-call Accept propagates — no structured swallow (C3)" do
364
+ # show_exceptions = :none on the shared app → Rails' default handling
365
+ # re-raises to the caller, exactly what a vanilla controller does.
366
+ expect do
367
+ post "/server_rescue/runtime_error", "{}", { "CONTENT_TYPE" => "application/json" }
368
+ end.to raise_error(RuntimeError, "boom")
369
+ end
370
+
371
+ it "a raise under a browser navigation Accept header also propagates (C3)" do
372
+ expect do
373
+ post "/server_rescue/runtime_error", "{}",
374
+ {
375
+ "CONTENT_TYPE" => "application/json",
376
+ "HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
377
+ }
378
+ end.to raise_error(RuntimeError, "boom")
379
+ end
380
+
381
+ it "a raise on a GET with Accept: application/json propagates — stock Rails behavior (review patch)" do
382
+ # Function calls are non-GET by the verb rule (epic contract decision
383
+ # #1); a GET carrying a JSON Accept (fetch() to a page action, API
384
+ # probes) must NOT be swallowed into the structured payload.
385
+ expect do
386
+ get "/server_rescue/erroring_page", {}, { "HTTP_ACCEPT" => "application/json" }
387
+ end.to raise_error(RuntimeError, "boom on GET")
388
+ end
389
+
390
+ it "a raise on a HEAD with Accept: application/json also propagates (review patch)" do
391
+ expect do
392
+ head "/server_rescue/erroring_page", {}, { "HTTP_ACCEPT" => "application/json" }
393
+ end.to raise_error(RuntimeError, "boom on GET")
394
+ end
395
+
396
+ it "a Ruact::ConfigurationError on a function-call request propagates — config stays loud (review patch round 3)" do
397
+ # Configuration invariants (e.g. the upload-guard ordering check) must
398
+ # NOT be folded into a structured 500 just because the request is a
399
+ # function call: a swallowed ConfigurationError reads as a transient
400
+ # server error instead of the setup mistake it is.
401
+ expect do
402
+ post "/server_rescue/config_error", "{}", function_call_headers
403
+ end.to raise_error(Ruact::ConfigurationError, "config invariant violated")
404
+ end
405
+
406
+ it "a manual UploadTooLargeError raised on a GET propagates — no structured 413 swallow (review patch)" do
407
+ # The UploadTooLargeError exception to the re-raise rule is gated behind
408
+ # the verb check: on a GET (where the guard never fires — D2) a manual
409
+ # raise keeps stock Rails behavior instead of rendering the structured
410
+ # 413, so GET pages stay byte-for-byte untouched (AC1).
411
+ expect do
412
+ get "/server_rescue/upload_error_on_get", {}, { "HTTP_ACCEPT" => "application/json" }
413
+ end.to raise_error(Ruact::UploadTooLargeError)
414
+ end
415
+ end
416
+ end