ruact 0.0.2 → 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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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 "#
|
|
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(:
|
|
43
|
+
expect(controller.send(:ruact_manifest)).to be test_manifest
|
|
32
44
|
end
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
describe "#
|
|
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(:
|
|
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(:
|
|
55
|
+
expect(controller.send(:ruact_request?)).to be true
|
|
44
56
|
end
|
|
45
57
|
|
|
46
|
-
it "returns true when
|
|
47
|
-
fake_request.headers["
|
|
48
|
-
expect(controller.send(:
|
|
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(:
|
|
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(:
|
|
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(:
|
|
67
|
-
allow(controller).to receive(:
|
|
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
|
|
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(:
|
|
85
|
+
expect(controller).to have_received(:ruact_render)
|
|
74
86
|
end
|
|
75
87
|
|
|
76
|
-
it "calls
|
|
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["
|
|
90
|
+
fake_request.headers["Ruact-Request"] = "1"
|
|
79
91
|
controller.send(:default_render)
|
|
80
|
-
expect(controller).to have_received(:
|
|
92
|
+
expect(controller).to have_received(:ruact_render)
|
|
81
93
|
end
|
|
82
94
|
|
|
83
|
-
it "does NOT call
|
|
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(:
|
|
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(:
|
|
103
|
+
expect(controller).not_to have_received(:ruact_render)
|
|
92
104
|
end
|
|
93
105
|
|
|
94
|
-
it "does NOT call
|
|
95
|
-
allow(controller).to receive(:
|
|
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(:
|
|
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(:
|
|
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(
|
|
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(
|
|
145
|
-
|
|
146
|
-
expect(
|
|
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(
|
|
155
|
-
|
|
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(
|
|
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(
|
|
165
|
-
|
|
166
|
-
expect(
|
|
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 "#
|
|
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(:
|
|
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(:
|
|
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(:
|
|
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(:
|
|
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(""quoted" & <evil>")
|
|
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
|