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
@@ -2,6 +2,18 @@
2
2
 
3
3
  require "spec_helper"
4
4
  require "active_support/concern"
5
+ # Re-run-5 (2026-05-15) — the `:current_user` inherited-helper clobber
6
+ # test creates a `Class.new(ActionController::Base)`, which requires
7
+ # `action_controller` to be loaded. Pre-Re-run-5 this test was the
8
+ # first thing in the suite to demand-load `action_controller`,
9
+ # which had the side effect of triggering `Rails::Application`'s
10
+ # class definition AFTER spec_helper's `require "ruact"` ran — so
11
+ # `Ruact::Railtie` never got loaded into the process and downstream
12
+ # specs that depend on the Railtie's `to_prepare` hook would behave
13
+ # inconsistently. Loading both explicitly here makes the order
14
+ # deterministic.
15
+ require "action_controller"
16
+ require "ruact"
5
17
  require "ruact/controller"
6
18
 
7
19
  module Ruact
@@ -24,37 +36,37 @@ module Ruact
24
36
  let(:fake_request) { Struct.new(:headers, :format).new({}, nil) }
25
37
  let(:controller) { test_class.new(fake_request) }
26
38
 
27
- describe "#rsc_manifest" do
39
+ describe "#ruact_manifest" do
28
40
  it "reads from Ruact.manifest (AC#6)" do
29
41
  test_manifest = ClientManifest.from_hash({})
30
42
  allow(Ruact).to receive(:manifest).and_return(test_manifest)
31
- expect(controller.send(:rsc_manifest)).to be test_manifest
43
+ expect(controller.send(:ruact_manifest)).to be test_manifest
32
44
  end
33
45
  end
34
46
 
35
- describe "#rsc_request?" do
47
+ describe "#ruact_request?" do
36
48
  it "returns true when Accept: text/x-component" do
37
49
  fake_request.headers["Accept"] = "text/x-component"
38
- expect(controller.send(:rsc_request?)).to be true
50
+ expect(controller.send(:ruact_request?)).to be true
39
51
  end
40
52
 
41
53
  it "returns true when Accept header includes text/x-component alongside other types" do
42
54
  fake_request.headers["Accept"] = "text/x-component, */*"
43
- expect(controller.send(:rsc_request?)).to be true
55
+ expect(controller.send(:ruact_request?)).to be true
44
56
  end
45
57
 
46
- it "returns true when RSC-Request: 1 header is set" do
47
- fake_request.headers["RSC-Request"] = "1"
48
- expect(controller.send(:rsc_request?)).to be true
58
+ it "returns true when Ruact-Request: 1 header is set" do
59
+ fake_request.headers["Ruact-Request"] = "1"
60
+ expect(controller.send(:ruact_request?)).to be true
49
61
  end
50
62
 
51
63
  it "returns false when Accept: text/html" do
52
64
  fake_request.headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
53
- expect(controller.send(:rsc_request?)).to be false
65
+ expect(controller.send(:ruact_request?)).to be false
54
66
  end
55
67
 
56
68
  it "returns false when no Accept header is set" do
57
- expect(controller.send(:rsc_request?)).to be false
69
+ expect(controller.send(:ruact_request?)).to be false
58
70
  end
59
71
  end
60
72
 
@@ -63,43 +75,43 @@ module Ruact
63
75
  let(:json_format) { Class.new { def html? = false }.new }
64
76
 
65
77
  before do
66
- allow(controller).to receive(:rsc_template_exists?).and_return(true)
67
- allow(controller).to receive(:rsc_render)
78
+ allow(controller).to receive(:ruact_template_exists?).and_return(true)
79
+ allow(controller).to receive(:ruact_render)
68
80
  end
69
81
 
70
- it "calls rsc_render when format is HTML and template exists (AC#1)" do
82
+ it "calls ruact_render when format is HTML and template exists (AC#1)" do
71
83
  allow(fake_request).to receive(:format).and_return(html_format)
72
84
  controller.send(:default_render)
73
- expect(controller).to have_received(:rsc_render)
85
+ expect(controller).to have_received(:ruact_render)
74
86
  end
75
87
 
76
- it "calls rsc_render for RSC requests (text/x-component) even without html? (AC#1)" do
88
+ it "calls ruact_render for RSC requests (text/x-component) even without html? (AC#1)" do
77
89
  allow(fake_request).to receive(:format).and_return(json_format)
78
- fake_request.headers["RSC-Request"] = "1"
90
+ fake_request.headers["Ruact-Request"] = "1"
79
91
  controller.send(:default_render)
80
- expect(controller).to have_received(:rsc_render)
92
+ expect(controller).to have_received(:ruact_render)
81
93
  end
82
94
 
83
- it "does NOT call rsc_render when format is not HTML and not RSC (AC#5, AC#6 — FR26)" do
95
+ it "does NOT call ruact_render when format is not HTML and not RSC (AC#5, AC#6 — FR26)" do
84
96
  allow(fake_request).to receive(:format).and_return(json_format)
85
- allow(controller).to receive(:rsc_render)
97
+ allow(controller).to receive(:ruact_render)
86
98
  begin
87
99
  controller.send(:default_render)
88
100
  rescue StandardError
89
101
  nil
90
102
  end
91
- expect(controller).not_to have_received(:rsc_render)
103
+ expect(controller).not_to have_received(:ruact_render)
92
104
  end
93
105
 
94
- it "does NOT call rsc_render when no template exists" do
95
- allow(controller).to receive(:rsc_template_exists?).and_return(false)
106
+ it "does NOT call ruact_render when no template exists" do
107
+ allow(controller).to receive(:ruact_template_exists?).and_return(false)
96
108
  allow(fake_request).to receive(:format).and_return(html_format)
97
109
  begin
98
110
  controller.send(:default_render)
99
111
  rescue StandardError
100
112
  nil
101
113
  end
102
- expect(controller).not_to have_received(:rsc_render)
114
+ expect(controller).not_to have_received(:ruact_render)
103
115
  end
104
116
  end
105
117
 
@@ -130,7 +142,7 @@ module Ruact
130
142
  end
131
143
  end
132
144
 
133
- let(:rsc_ctrl) do
145
+ let(:ruact_ctrl) do
134
146
  redirect_test_class.new(Struct.new(:headers, :host).new({ "Accept" => "text/x-component" }, "localhost"))
135
147
  end
136
148
  let(:html_ctrl) do
@@ -138,12 +150,12 @@ module Ruact
138
150
  end
139
151
 
140
152
  context "when RSC request with same-origin URL (AC #1, #5)" do
141
- before { allow(rsc_ctrl).to receive(:url_for).and_return("/posts/1") }
153
+ before { allow(ruact_ctrl).to receive(:url_for).and_return("/posts/1") }
142
154
 
143
155
  it "calls render with a Flight redirect row (not a 302)" do
144
- allow(rsc_ctrl).to receive(:render)
145
- rsc_ctrl.send(:redirect_to, "/posts/1")
146
- expect(rsc_ctrl).to have_received(:render).with(
156
+ allow(ruact_ctrl).to receive(:render)
157
+ ruact_ctrl.send(:redirect_to, "/posts/1")
158
+ expect(ruact_ctrl).to have_received(:render).with(
147
159
  plain: "0:{\"redirectUrl\":\"/posts/1\",\"redirectType\":\"push\"}\n",
148
160
  content_type: "text/x-component"
149
161
  )
@@ -151,19 +163,19 @@ module Ruact
151
163
 
152
164
  it "redirect row matches the flight fixture (AC #5)" do
153
165
  rendered_plain = nil
154
- allow(rsc_ctrl).to receive(:render) { |opts| rendered_plain = opts[:plain] }
155
- rsc_ctrl.send(:redirect_to, "/posts/1")
166
+ allow(ruact_ctrl).to receive(:render) { |opts| rendered_plain = opts[:plain] }
167
+ ruact_ctrl.send(:redirect_to, "/posts/1")
156
168
  expect(rendered_plain).to match_flight_fixture("redirect_row")
157
169
  end
158
170
  end
159
171
 
160
172
  context "when RSC request with external URL (AC #3)" do
161
- before { allow(rsc_ctrl).to receive(:url_for).and_return("https://external.com/page") }
173
+ before { allow(ruact_ctrl).to receive(:url_for).and_return("https://external.com/page") }
162
174
 
163
175
  it "does NOT emit a redirect row (falls back to super)" do
164
- allow(rsc_ctrl).to receive(:render)
165
- rsc_ctrl.send(:redirect_to, "https://external.com/page")
166
- expect(rsc_ctrl).not_to have_received(:render)
176
+ allow(ruact_ctrl).to receive(:render)
177
+ ruact_ctrl.send(:redirect_to, "https://external.com/page")
178
+ expect(ruact_ctrl).not_to have_received(:render)
167
179
  end
168
180
  end
169
181
 
@@ -178,36 +190,412 @@ module Ruact
178
190
  end
179
191
  end
180
192
 
181
- describe "#rsc_html_shell" do
193
+ describe "#ruact_html_shell" do
182
194
  # vite_tags requires Rails.env — stub it so we can test the shell structure.
183
195
  before { allow(controller).to receive(:vite_tags).and_return("") }
184
196
 
185
197
  let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
186
198
 
187
199
  it "returns a string containing window.__FLIGHT_DATA" do
188
- html = controller.send(:rsc_html_shell, payload)
200
+ html = controller.send(:ruact_html_shell, payload)
189
201
  expect(html).to include("__FLIGHT_DATA")
190
202
  end
191
203
 
192
204
  it "wraps the payload in an IIFE push" do
193
- html = controller.send(:rsc_html_shell, payload)
205
+ html = controller.send(:ruact_html_shell, payload)
194
206
  expect(html).to include("d.push(")
195
207
  end
196
208
 
197
209
  it "contains a root div#root element" do
198
- html = controller.send(:rsc_html_shell, payload)
210
+ html = controller.send(:ruact_html_shell, payload)
199
211
  expect(html).to include('<div id="root">')
200
212
  end
201
213
 
202
214
  it "escapes </script> in the payload to prevent XSS breakout" do
203
215
  dangerous_payload = "0:\"</script><script>alert(1)</script>\"\n"
204
- html = controller.send(:rsc_html_shell, dangerous_payload)
216
+ html = controller.send(:ruact_html_shell, dangerous_payload)
205
217
  # The HTML must contain exactly ONE </script> — the real closing tag of the script block.
206
218
  # If the payload's </script> leaked through, there would be more than one.
207
219
  occurrences = html.scan("</script>")
208
220
  count = occurrences.length
209
221
  expect(count).to eq(1), "Expected 1 </script> (closing tag), found #{count}"
210
222
  end
223
+
224
+ # Story 8.3 review R7 — the shell must surface the host's CSRF token
225
+ # as a `<meta name="csrf-token">` tag so the JS runtime can forward
226
+ # it as `X-CSRF-Token` on every `_makeRef` call. Without this,
227
+ # standalone server actions (Story 8.3 AC5) — for which the gem
228
+ # enforces CSRF itself via `protect_from_forgery with: :exception,
229
+ # if: :dispatching_standalone?` — can never authenticate, because
230
+ # the document has no token for the runtime to read.
231
+ context "Story 8.3 — CSRF meta tag injection", :story_8_3 do
232
+ # The bare `test_class` above is a minimal `include Ruact::Controller`
233
+ # consumer with no `form_authenticity_token` surface (no Rails
234
+ # request-forgery-protection module mixed in). Define a real method
235
+ # on the class so `respond_to?(:form_authenticity_token, true)` is
236
+ # true AND `verify_partial_doubles` lets us stub the return value.
237
+ let(:csrf_test_class) do
238
+ Class.new(test_class) do
239
+ def form_authenticity_token
240
+ "stub-default-token"
241
+ end
242
+ end
243
+ end
244
+ let(:csrf_controller) { csrf_test_class.new(fake_request) }
245
+
246
+ it "embeds <meta name=\"csrf-token\" content=\"...\"> when the host exposes form_authenticity_token" do
247
+ allow(csrf_controller).to receive(:form_authenticity_token).and_return("test-csrf-token-value")
248
+ html = csrf_controller.send(:ruact_html_shell, payload)
249
+ expect(html).to include('<meta name="csrf-token" content="test-csrf-token-value" />')
250
+ end
251
+
252
+ it "HTML-escapes the token value (defense against accidental quote injection)" do
253
+ allow(csrf_controller).to receive(:form_authenticity_token).and_return('"quoted" & <evil>')
254
+ html = csrf_controller.send(:ruact_html_shell, payload)
255
+ expect(html).to include("&quot;quoted&quot; &amp; &lt;evil&gt;")
256
+ expect(html).not_to include('"quoted" & <evil>')
257
+ end
258
+
259
+ it "omits the meta tag (renders empty string) when form_authenticity_token is not available " \
260
+ "(e.g., a non-Rails spec context)" do
261
+ # `controller` is the bare test_class without `form_authenticity_token`.
262
+ html = controller.send(:ruact_html_shell, payload)
263
+ expect(html).not_to include('name="csrf-token"')
264
+ end
265
+
266
+ it "omits the meta tag when form_authenticity_token returns nil/empty" do
267
+ allow(csrf_controller).to receive(:form_authenticity_token).and_return("")
268
+ html = csrf_controller.send(:ruact_html_shell, payload)
269
+ expect(html).not_to include('name="csrf-token"')
270
+ end
271
+
272
+ it "silently omits the meta tag if form_authenticity_token raises " \
273
+ "(e.g., session middleware missing in a stripped-down test env)" do
274
+ allow(csrf_controller).to receive(:form_authenticity_token).and_raise(StandardError, "no session")
275
+ expect { csrf_controller.send(:ruact_html_shell, payload) }.not_to raise_error
276
+ html = csrf_controller.send(:ruact_html_shell, payload)
277
+ expect(html).not_to include('name="csrf-token"')
278
+ end
279
+ end
280
+ end
281
+
282
+ describe "ruact_action DSL macro (Story 8.1)", :story_8_1 do
283
+ # The macro is class-level. Each example builds a fresh anonymous
284
+ # controller class so registry state is per-example. The registry
285
+ # itself is reset at the top of every example via Ruact.action_registry.clear!
286
+ # to avoid leakage across spec runs.
287
+ before { Ruact.action_registry.clear! }
288
+
289
+ it "registers an action in Ruact.action_registry with the correct kind, controller, and block" do
290
+ klass = Class.new do
291
+ def self.name = "ExampleController"
292
+ include Ruact::Controller
293
+
294
+ ruact_action(:create_post) { |params| "created #{params[:title]}" }
295
+ end
296
+
297
+ entry = Ruact.action_registry.entries[:create_post]
298
+ expect(entry).not_to be_nil
299
+ expect(entry.ruby_symbol).to eq(:create_post)
300
+ expect(entry.js_identifier).to eq("createPost")
301
+ expect(entry.kind).to eq(:action)
302
+ expect(entry.controller).to be(klass)
303
+ expect(entry.block).to be_a(Proc)
304
+ end
305
+
306
+ it "defines the action symbol as a PUBLIC method (Rails action dispatch requires public) " \
307
+ "with a thread-local guard that rejects non-endpoint invocations (review-batch 1 2026-05-14)" do
308
+ klass = Class.new do
309
+ def self.name = "ExampleController"
310
+ include Ruact::Controller
311
+
312
+ ruact_action(:create_post) { |params| "echo #{params[:title]}" }
313
+ end
314
+
315
+ instance = klass.allocate
316
+ # Public (so ActionController#process can dispatch it through the
317
+ # before_action chain when scoped `only: :create_post`).
318
+ expect(instance.respond_to?(:create_post)).to be(true)
319
+ # Direct call without the thread-local sentinel raises — closes the
320
+ # wildcard-route exposure where a host's `get ":controller/:action"`
321
+ # could otherwise reach the action via GET.
322
+ expect { instance.send(:create_post) }.to raise_error(Ruact::Error, /can only be invoked through POST/)
323
+ end
324
+
325
+ it "raises ArgumentError when no block is given" do
326
+ expect do
327
+ Class.new do
328
+ def self.name = "BadController"
329
+ include Ruact::Controller
330
+
331
+ ruact_action(:create_post)
332
+ end
333
+ end.to raise_error(ArgumentError, /requires a block/)
334
+ end
335
+
336
+ it "raises Ruact::ConfigurationError with the AC7 prefix on invalid symbol shape" do
337
+ expect do
338
+ Class.new do
339
+ def self.name = "BadController"
340
+ include Ruact::Controller
341
+
342
+ ruact_action(:CreatePost) { |_p| nil }
343
+ end
344
+ end.to raise_error(Ruact::ConfigurationError, /invalid server-function symbol :CreatePost in BadController/)
345
+ end
346
+
347
+ it "raises Ruact::ConfigurationError with the AC7 collision wording on within-registry duplicate js_identifier" do
348
+ expect do
349
+ Class.new do
350
+ def self.name = "CollidingController"
351
+ include Ruact::Controller
352
+
353
+ ruact_action(:foo_bar) { |_p| nil }
354
+ ruact_action(:foo__bar) { |_p| nil }
355
+ end
356
+ end.to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
357
+ end
358
+
359
+ it "raises Ruact::ConfigurationError when the symbol maps to a reserved JS word" do
360
+ expect do
361
+ Class.new do
362
+ def self.name = "BadController"
363
+ include Ruact::Controller
364
+
365
+ ruact_action(:delete) { |_p| nil }
366
+ end
367
+ end.to raise_error(Ruact::ConfigurationError, /reserved/)
368
+ end
369
+
370
+ it "raises Ruact::ConfigurationError when the symbol clobbers a framework method " \
371
+ "(review-batch 1 2026-05-14)" do
372
+ # `:params` is defined on ActionController::Base via Metal — declaring
373
+ # `ruact_action :params` would override the request-params accessor.
374
+ expect do
375
+ Class.new do
376
+ def self.name = "BadController"
377
+ include Ruact::Controller
378
+
379
+ ruact_action(:params) { |_p| nil }
380
+ end
381
+ end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
382
+ end
383
+
384
+ it "raises ArgumentError on a zero-arity block (review-batch 1 2026-05-14)" do
385
+ expect do
386
+ Class.new do
387
+ def self.name = "ExampleController"
388
+ include Ruact::Controller
389
+
390
+ ruact_action(:no_args) { "pong" }
391
+ end
392
+ end.to raise_error(ArgumentError, /must accept exactly one positional parameter/)
393
+ end
394
+
395
+ it "rejects a block with required keyword arguments (re-run-4 #4 — " \
396
+ "block.parameters guard catches `do |p, required:|`)" do
397
+ expect do
398
+ Class.new do
399
+ def self.name = "ExampleController"
400
+ include Ruact::Controller
401
+
402
+ ruact_action(:bad_kwargs) { |_params, required:| required }
403
+ end
404
+ end.to raise_error(ArgumentError, /no required keyword arguments/)
405
+ end
406
+
407
+ it "accepts optional keyword args alongside the positional (re-run-4 #4 — " \
408
+ "`do |params, opt: nil|` is fine)" do
409
+ expect do
410
+ Class.new do
411
+ def self.name = "ExampleController"
412
+ include Ruact::Controller
413
+
414
+ ruact_action(:opt_kwargs) { |_params, opt: nil| opt }
415
+ end
416
+ end.not_to raise_error
417
+ end
418
+
419
+ it "accepts a splat-arity block (do |*args|) since it tolerates one positional arg" do
420
+ expect do
421
+ Class.new do
422
+ def self.name = "ExampleController"
423
+ include Ruact::Controller
424
+
425
+ ruact_action(:splat_args) { |*args| args.first }
426
+ end
427
+ end.not_to raise_error
428
+ end
429
+
430
+ it "rejects a String argument (re-run-2 #4 — String key would 404 on dispatch)" do
431
+ expect do
432
+ Class.new do
433
+ def self.name = "BadController"
434
+ include Ruact::Controller
435
+
436
+ ruact_action("create_post") { |_p| nil }
437
+ end
438
+ end.to raise_error(ArgumentError, /requires a Symbol/)
439
+ end
440
+
441
+ it "rejects clobber of a method already defined on the host class itself " \
442
+ "(re-run-2 #3 — guard extended from framework methods to host methods)" do
443
+ expect do
444
+ Class.new do
445
+ def self.name = "BadController"
446
+ include Ruact::Controller
447
+
448
+ def index; end
449
+ ruact_action(:index) { |_p| nil }
450
+ end
451
+ end.to raise_error(Ruact::ConfigurationError, /would clobber an existing method/)
452
+ end
453
+
454
+ it "rejects clobber of an INHERITED app helper like :current_user " \
455
+ "(re-run-3 #2 — guard extended from own-class to inherited app methods)" do
456
+ # Stand-in for `ApplicationController` defining `current_user` and
457
+ # subclasses inheriting it; declaring `ruact_action :current_user`
458
+ # would silently override the auth helper.
459
+ app_controller = Class.new(ActionController::Base) do
460
+ def current_user; end
461
+ def authenticate_user!; end
462
+ end
463
+ expect do
464
+ Class.new(app_controller) do
465
+ def self.name = "BadController"
466
+ include Ruact::Controller
467
+
468
+ ruact_action(:current_user) { |_p| nil }
469
+ end
470
+ end.to raise_error(Ruact::ConfigurationError, /would clobber an inherited helper/)
471
+ end
472
+
473
+ it "rejects clobber of CSRF callback :verify_authenticity_token " \
474
+ "(re-run-5 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
475
+ expect do
476
+ Class.new do
477
+ def self.name = "BadController"
478
+ include Ruact::Controller
479
+
480
+ ruact_action(:verify_authenticity_token) { |_p| nil }
481
+ end
482
+ end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
483
+ end
484
+
485
+ it "rejects a block with MULTIPLE required positional parameters " \
486
+ "(re-run-5 #3 — `do |a, b|` silently received nil for `b`)" do
487
+ expect do
488
+ Class.new do
489
+ def self.name = "ExampleController"
490
+ include Ruact::Controller
491
+
492
+ ruact_action(:two_positional) { |_a, _b| nil }
493
+ end
494
+ end.to raise_error(ArgumentError, /must accept exactly one positional/)
495
+ end
496
+
497
+ it "rejects clobber of Kernel#send / Kernel#public_send " \
498
+ "(re-run-3 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
499
+ expect do
500
+ Class.new do
501
+ def self.name = "BadController"
502
+ include Ruact::Controller
503
+
504
+ ruact_action(:send) { |_p| nil }
505
+ end
506
+ end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
507
+ end
508
+
509
+ it "rejects clobber of an INHERITED ActionController::Base method like :status " \
510
+ "(re-run-6 #2 — denylist widened from hardcoded set to all framework methods - Object methods)" do
511
+ expect do
512
+ Class.new do
513
+ def self.name = "BadController"
514
+
515
+ include Ruact::Controller
516
+
517
+ ruact_action(:status) { |_p| nil }
518
+ end
519
+ end.to raise_error(Ruact::ConfigurationError,
520
+ /would clobber an inherited ActionController::Base method/)
521
+ end
522
+
523
+ it "rejects a LATER def that overrides a previously-registered " \
524
+ "ruact_action method in the same class body (re-run-6 #1 — method_added hook)" do
525
+ # The macro defines the action method; a later `def create_post; end`
526
+ # would silently shadow it, causing host_class.dispatch to skip the
527
+ # sentinel guard and run the user's later body. Detect at class-load
528
+ # time via method_added hook so the error is loud at boot.
529
+ expect do
530
+ Class.new do
531
+ def self.name = "BadController"
532
+
533
+ include Ruact::Controller
534
+
535
+ ruact_action(:create_post) { |_p| "from macro" }
536
+
537
+ def create_post
538
+ "from later def"
539
+ end
540
+ end
541
+ end.to raise_error(Ruact::ConfigurationError,
542
+ /registered by `ruact_action :create_post` and then re-defined/)
543
+ end
544
+
545
+ it "preserves a pre-existing `method_added` hook on the host class " \
546
+ "(re-run-7 #1 — prepended Module instead of define_method clobber)" do
547
+ # Apps and concerns commonly install `method_added` for
548
+ # instrumentation, DSL bookkeeping (`attr_*` style helpers), or
549
+ # auditing. If the gem replaced the host's `method_added` instead
550
+ # of chaining through it, those concerns would stop firing as soon
551
+ # as the first `ruact_action` runs.
552
+ recorded = []
553
+ klass = Class.new do
554
+ def self.name = "ExampleController"
555
+
556
+ singleton_class.define_method(:method_added) do |meth|
557
+ recorded << meth
558
+ super(meth)
559
+ end
560
+
561
+ include Ruact::Controller
562
+
563
+ ruact_action(:create_post) { |_p| "from macro" }
564
+
565
+ def helper_method
566
+ :ok
567
+ end
568
+ end
569
+
570
+ # Both the macro-defined :create_post AND the later `def helper_method`
571
+ # should have been observed by the host's `method_added`.
572
+ expect(recorded).to include(:create_post, :helper_method)
573
+ # The action method is still registered and dispatch-guarded.
574
+ expect(klass.allocate.respond_to?(:create_post)).to be(true)
575
+ end
576
+ end
577
+
578
+ describe "ruact_action cross-controller collisions (Story 8.1 — re-run-3)" do
579
+ before { Ruact.action_registry.clear! }
580
+
581
+ it "raises Ruact::ConfigurationError when the SAME symbol is declared on TWO controllers " \
582
+ "(re-run-3 #1 — silent overwrite would route to whichever loaded last)" do
583
+ Class.new do
584
+ def self.name = "PostsController"
585
+ include Ruact::Controller
586
+
587
+ ruact_action(:create_post) { |_p| nil }
588
+ end
589
+
590
+ expect do
591
+ Class.new do
592
+ def self.name = "AdminPostsController"
593
+ include Ruact::Controller
594
+
595
+ ruact_action(:create_post) { |_p| nil }
596
+ end
597
+ end.to raise_error(Ruact::ConfigurationError, /declared in BOTH/)
598
+ end
211
599
  end
212
600
  end
213
601
  end