ruact 0.0.1 → 0.0.3

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 (129) 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 +86 -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 +1680 -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 +89 -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 +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -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
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.1 — unit surface of the `Ruact::Server` concern (route-driven
4
+ # redesign, Phase A). Pins:
5
+ #
6
+ # - AC1 "and nothing else": including the concern installs EXACTLY two
7
+ # `rescue_from` handlers + one prepended before_action, adds no public
8
+ # instance methods, and registers nothing in the v1 registries (codegen
9
+ # exposure is Story 9.3's job — the concern is a pure marker + salvage
10
+ # host until then).
11
+ # - AC2 / D3: the `__ruact_function_call?` predicate matrix — the single
12
+ # named discrimination point Story 9.2 reuses. Keyed on the raw `Accept`
13
+ # header containing `application/json` (what the 8.1 runtime sends on
14
+ # every `_makeRef` fetch); deliberately NOT `request.format`, which is
15
+ # influenced by path extensions and `params[:format]`.
16
+ #
17
+ # Request-cycle behavior (error chain, upload guard) is pinned by
18
+ # `server_rescue_request_spec.rb` / `server_upload_request_spec.rb`.
19
+
20
+ require "action_controller/railtie"
21
+
22
+ require "spec_helper"
23
+ require "open3"
24
+
25
+ require "ruact/server"
26
+
27
+ module ServerConcernUnitSupport
28
+ # Baseline for "nothing else" comparisons.
29
+ class PlainController < ActionController::Base
30
+ end
31
+
32
+ class ConcernController < ActionController::Base
33
+ include Ruact::Server
34
+ end
35
+ end
36
+
37
+ RSpec.describe Ruact::Server, :story_9_1 do
38
+ describe "Story 9.1 — installation surface (AC1: the salvaged chains and nothing else)" do
39
+ it "installs exactly the two salvaged rescue_from handlers, in the v1 registration order" do
40
+ # Pitfall #1 parity: StandardError first, explicit
41
+ # InvalidAuthenticityToken second — the later registration wins the
42
+ # most-recently-registered walk, preempting Rails' default
43
+ # handle_unverified_request for CSRF failures.
44
+ expect(ServerConcernUnitSupport::PlainController.rescue_handlers).to eq([])
45
+ expect(ServerConcernUnitSupport::ConcernController.rescue_handlers.map(&:first)).to eq(
46
+ ["StandardError", "ActionController::InvalidAuthenticityToken"]
47
+ )
48
+ end
49
+
50
+ it "prepends the upload guard as the FIRST before_action (Pitfall #4 ordering)" do
51
+ before_filters = ServerConcernUnitSupport::ConcernController
52
+ ._process_action_callbacks
53
+ .select { |callback| callback.kind == :before }
54
+ .map(&:filter)
55
+ expect(before_filters.first).to eq(:__ruact_enforce_upload_limit!)
56
+ end
57
+
58
+ it "adds NO public instance methods to the host (predicate + handlers are private)" do
59
+ added = ServerConcernUnitSupport::ConcernController.public_instance_methods -
60
+ ServerConcernUnitSupport::PlainController.public_instance_methods
61
+ expect(added).to eq([])
62
+ end
63
+
64
+ it "registers nothing in the v1 registries (codegen exposure is Story 9.3, not 9.1)" do
65
+ expect(Ruact.action_registry.entries).to be_empty
66
+ expect(Ruact.query_registry.entries).to be_empty
67
+ end
68
+
69
+ it "keeps INHERITED host rescue_from handlers more recent than its own (review patch)",
70
+ :aggregate_failures do
71
+ # Rails resolves handlers by walking `rescue_handlers` from the most
72
+ # recently registered entry backwards. The concern therefore places its
73
+ # entries at the FRONT of the array, so every host handler — inherited
74
+ # from a parent class or declared after the include — stays more recent
75
+ # and keeps precedence.
76
+ parent = Class.new(ActionController::Base) do
77
+ rescue_from ArgumentError, with: :host_handler
78
+ end
79
+ child = Class.new(parent) { include Ruact::Server }
80
+ expect(child.rescue_handlers.map(&:first)).to eq(
81
+ ["StandardError", "ActionController::InvalidAuthenticityToken", "ArgumentError"]
82
+ )
83
+ # The parent's own registry is untouched (class_attribute write lands
84
+ # on the child only).
85
+ expect(parent.rescue_handlers.map(&:first)).to eq(["ArgumentError"])
86
+ end
87
+ end
88
+
89
+ describe "Story 9.1 — standalone load path (review patch)" do
90
+ it "a direct require \"ruact/server\" resolves Ruact.config and the error constants" do
91
+ lib = File.expand_path("../../lib", __dir__)
92
+ script = <<~RUBY
93
+ require "ruact/server"
94
+ exit 1 unless defined?(Ruact::Server)
95
+ exit 2 unless defined?(Ruact::UploadTooLargeError)
96
+ exit 3 unless Ruact.config.respond_to?(:max_upload_bytes)
97
+ exit 4 unless defined?(Ruact::ServerFunctions::ErrorRendering)
98
+ RUBY
99
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", lib, "-e", script)
100
+ expect(status).to be_success, "standalone require failed (exit #{status.exitstatus}): #{stderr}"
101
+ end
102
+ end
103
+
104
+ # Simplified Story 9.1 contract — the runtime sends the exact
105
+ # `Accept: application/json` shape, and that exact header is the only
106
+ # JSON-Accept signal this concern recognizes.
107
+ describe "Story 9.1 — __ruact_json_accept? exact-header matrix" do
108
+ let(:controller) { ServerConcernUnitSupport::ConcernController.new }
109
+
110
+ def stub_accept_header(value)
111
+ request = instance_double(ActionDispatch::Request)
112
+ allow(request).to receive(:headers).and_return({ "Accept" => value })
113
+ allow(controller).to receive(:request).and_return(request)
114
+ end
115
+
116
+ it "is true for the runtime's exact shape (Accept: application/json)" do
117
+ stub_accept_header("application/json")
118
+ expect(controller.send(:__ruact_json_accept?)).to be(true)
119
+ end
120
+
121
+ it "is false for a composite Accept header" do
122
+ stub_accept_header("application/json, text/plain, */*")
123
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
124
+ end
125
+
126
+ it "is false for browser navigation Accept headers" do
127
+ stub_accept_header("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
128
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
129
+ end
130
+
131
+ it "is false for Flight requests (Accept: text/x-component)" do
132
+ stub_accept_header("text/x-component")
133
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
134
+ end
135
+
136
+ it "is false when the Accept header is absent (strict boolean, not nil)" do
137
+ stub_accept_header(nil)
138
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
139
+ end
140
+ end
141
+
142
+ # Review patch (2026-06-08) — `__ruact_function_call?` is now the SEMANTIC
143
+ # predicate Story 9.2 reuses: a JSON-Accept request that is ALSO non-GET/HEAD
144
+ # (function calls are non-GET by the verb rule, epic contract decision #1).
145
+ # The verb gate moved off the error-renderer and into the predicate itself,
146
+ # so 9.2 inherits the correct contract from one place.
147
+ describe "Story 9.1 — __ruact_function_call? semantic predicate (verb-gated, review patch)" do
148
+ let(:controller) { ServerConcernUnitSupport::ConcernController.new }
149
+
150
+ def stub_request(accept:, verb: "POST")
151
+ request = instance_double(
152
+ ActionDispatch::Request,
153
+ headers: { "Accept" => accept },
154
+ get?: verb == "GET",
155
+ head?: verb == "HEAD"
156
+ )
157
+ allow(controller).to receive(:request).and_return(request)
158
+ end
159
+
160
+ it "is true for a non-GET JSON request (THE Story 9.2 discrimination point)" do
161
+ stub_request(accept: "application/json", verb: "POST")
162
+ expect(controller.send(:__ruact_function_call?)).to be(true)
163
+ end
164
+
165
+ it "is false for a GET carrying Accept: application/json (verb rule — not a function call)" do
166
+ stub_request(accept: "application/json", verb: "GET")
167
+ expect(controller.send(:__ruact_function_call?)).to be(false)
168
+ end
169
+
170
+ it "is false for a HEAD carrying Accept: application/json" do
171
+ stub_request(accept: "application/json", verb: "HEAD")
172
+ expect(controller.send(:__ruact_function_call?)).to be(false)
173
+ end
174
+
175
+ it "is false for a non-GET request without a JSON Accept (Bucket-1 form submit)" do
176
+ stub_request(accept: "text/html", verb: "POST")
177
+ expect(controller.send(:__ruact_function_call?)).to be(false)
178
+ end
179
+ end
180
+ end