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,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe Configuration do
7
+ # Reset the singleton + boot flag around every example so order randomization
8
+ # is safe and the warning-on-second-call contract can be exercised cleanly.
9
+ # Save and restore the original Rails.logger so we never clobber a logger a
10
+ # later randomized example expects to find in place.
11
+ around do |example|
12
+ Ruact.instance_variable_set(:@config, nil)
13
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
14
+ original_rails_logger = Rails.respond_to?(:logger) ? Rails.logger : nil
15
+ example.run
16
+ ensure
17
+ Ruact.instance_variable_set(:@config, nil)
18
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
19
+ Rails.logger = original_rails_logger if Rails.respond_to?(:logger=)
20
+ end
21
+
22
+ # Default: silence the [ruact] re-configuration warning so unrelated specs
23
+ # that legitimately call Ruact.configure twice do not pollute stderr.
24
+ # The "re-configuration warning" describe overrides this with its own assertions.
25
+ before do
26
+ Rails.singleton_class.send(:attr_accessor, :logger) unless Rails.respond_to?(:logger=)
27
+ Rails.logger = instance_double(::Logger, warn: nil)
28
+ end
29
+
30
+ describe ".configure freezes the configuration" do
31
+ it "is frozen the moment the configure block returns (AC1)" do
32
+ Ruact.configure { |c| c.suspense_timeout = 7.0 }
33
+ expect(Ruact.config).to be_frozen
34
+ end
35
+
36
+ it "preserves attribute reads after freeze (AC1)" do
37
+ Ruact.configure do |c|
38
+ c.suspense_timeout = 8.0
39
+ c.strict_serialization = true
40
+ c.vite_dev_server = "http://localhost:9999"
41
+ c.manifest_path = "/tmp/manifest.json"
42
+ end
43
+
44
+ expect(Ruact.config.suspense_timeout).to eq(8.0)
45
+ expect(Ruact.config.strict_serialization).to be(true)
46
+ expect(Ruact.config.vite_dev_server).to eq("http://localhost:9999")
47
+ expect(Ruact.config.manifest_path).to eq("/tmp/manifest.json")
48
+ end
49
+ end
50
+
51
+ describe "post-boot mutation" do
52
+ before { Ruact.configure { |c| c.suspense_timeout = 5.0 } }
53
+
54
+ Configuration::ATTRIBUTES.each do |attr|
55
+ it "raises Ruact::ConfigurationError when ##{attr} is assigned outside Ruact.configure (AC2)" do
56
+ expect { Ruact.config.public_send("#{attr}=", "anything") }
57
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration##{attr}/)
58
+ end
59
+ end
60
+
61
+ it "error message includes the AC2 verbatim suggested-fix sentence (AC2.3)" do
62
+ expect { Ruact.config.suspense_timeout = 12.0 }
63
+ .to raise_error(
64
+ Ruact::ConfigurationError,
65
+ include("Wrap the change in Ruact.configure { |c| c.suspense_timeout = ... } " \
66
+ "in config/initializers/ruact.rb and restart the process.")
67
+ )
68
+ end
69
+
70
+ it "error message includes the design intent (AC2.4)" do
71
+ expect { Ruact.config.suspense_timeout = 12.0 }
72
+ .to raise_error(
73
+ Ruact::ConfigurationError,
74
+ /Story 7\.3.*runtime config drift/m
75
+ )
76
+ end
77
+ end
78
+
79
+ describe "deep-freeze of attribute values (AC2 — bypass guard)" do
80
+ it "freezes mutable string values so in-place mutation cannot bypass the writer guard" do
81
+ Ruact.configure { |c| c.manifest_path = +"/tmp/manifest.json" }
82
+
83
+ expect(Ruact.config.manifest_path).to be_frozen
84
+ expect { Ruact.config.manifest_path.replace("/tmp/elsewhere.json") }
85
+ .to raise_error(FrozenError)
86
+ end
87
+
88
+ it "freezes vite_dev_server even when only defaults are used (AC5)" do
89
+ # No configure block — first access publishes the default-frozen Configuration.
90
+ expect(Ruact.config.vite_dev_server).to be_frozen
91
+ end
92
+
93
+ it "carries the deep-freeze invariant through atomic re-configuration" do
94
+ Ruact.configure { |c| c.manifest_path = +"/tmp/first.json" }
95
+ Ruact.configure { |c| c.suspense_timeout = 9.0 } # does NOT touch manifest_path
96
+
97
+ # The second configure clones from the first; manifest_path retains the
98
+ # frozen value, not a fresh mutable reference.
99
+ expect(Ruact.config.manifest_path).to be_frozen
100
+ end
101
+
102
+ it "values are mutable inside the configure block; freeze happens only after the block returns (AC1)" do
103
+ # AC1: "the DSL inside the block is unchanged. The freeze happens after
104
+ # the block returns, not before." Deep-freeze of attribute values must
105
+ # therefore happen at publication time, not at writer time — otherwise
106
+ # a developer who in-place-mutates a value within the same configure
107
+ # block (a documented Ruby idiom) would see a confusing FrozenError.
108
+ captured_inside_block = nil
109
+
110
+ Ruact.configure do |c|
111
+ c.manifest_path = +"/tmp/a.json"
112
+ captured_inside_block = c.manifest_path
113
+ expect(captured_inside_block).not_to be_frozen
114
+ c.manifest_path.replace("/tmp/b.json") # idiomatic in-place mutation, must work
115
+ expect(c.manifest_path).to eq("/tmp/b.json")
116
+ end
117
+
118
+ # After the block returns, publication has deep-frozen the value.
119
+ expect(Ruact.config.manifest_path).to eq("/tmp/b.json")
120
+ expect(Ruact.config.manifest_path).to be_frozen
121
+ expect { Ruact.config.manifest_path.replace("/tmp/c.json") }.to raise_error(FrozenError)
122
+ end
123
+
124
+ it "template-inherited values are mutable inside a second configure block (AC1, F9)" do
125
+ # F9: After the first publication, the published config has deep-frozen
126
+ # values. The second Ruact.configure call clones into a draft via
127
+ # Configuration.new(template:); that draft must be unfrozen so the AC1
128
+ # contract holds for re-configuration too — including idiomatic
129
+ # in-place mutation of inherited values.
130
+ Ruact.configure { |c| c.manifest_path = +"/tmp/a.json" }
131
+ expect(Ruact.config.manifest_path).to be_frozen # baseline: first publication froze the value
132
+
133
+ Ruact.configure do |c|
134
+ expect(c.manifest_path).not_to be_frozen # draft cloned the value into an unfrozen dup
135
+ c.manifest_path.replace("/tmp/b.json") # idiomatic in-place mutation on inherited value
136
+ expect(c.manifest_path).to eq("/tmp/b.json")
137
+ end
138
+
139
+ # After the second block returns, publication freezes the draft's value.
140
+ expect(Ruact.config.manifest_path).to eq("/tmp/b.json")
141
+ expect(Ruact.config.manifest_path).to be_frozen
142
+ end
143
+ end
144
+
145
+ describe "public API surface (AC1, AC7, AC9 — F10)" do
146
+ it "does not expose seal! on Ruact::Configuration" do
147
+ # seal! is a private implementation detail invoked by Ruact.configure /
148
+ # Ruact.config via __send__. The public API surface is the four
149
+ # readers + their writers (only callable inside a configure block) —
150
+ # nothing else. External callers reaching into Ruact.config.seal!
151
+ # must hit NoMethodError, not silently re-freeze.
152
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
153
+
154
+ expect(described_class.public_instance_methods(false)).not_to include(:seal!)
155
+ expect(Ruact.config).not_to respond_to(:seal!)
156
+ expect { Ruact.config.seal! }.to raise_error(NoMethodError, /private method/)
157
+ end
158
+
159
+ it "exposes only the documented readers + writers on the public surface" do
160
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
161
+
162
+ public_attrs = described_class.public_instance_methods(false)
163
+ expected = Configuration::ATTRIBUTES + Configuration::ATTRIBUTES.map { |a| :"#{a}=" }
164
+
165
+ expect(public_attrs.sort).to eq(expected.sort)
166
+ end
167
+ end
168
+
169
+ describe "atomic re-configuration" do
170
+ it "produces a frozen Configuration after both calls (AC3)" do
171
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
172
+ first_config = Ruact.config
173
+
174
+ Ruact.configure { |c| c.suspense_timeout = 9.0 }
175
+ second_config = Ruact.config
176
+
177
+ expect(first_config).to be_frozen
178
+ expect(second_config).to be_frozen
179
+ expect(second_config).not_to equal(first_config)
180
+ expect(second_config.suspense_timeout).to eq(9.0)
181
+ end
182
+
183
+ it "clones every attribute from the previous configuration (AC3)" do
184
+ Ruact.configure do |c|
185
+ c.suspense_timeout = 7.5
186
+ c.strict_serialization = true
187
+ end
188
+
189
+ Ruact.configure { |c| c.vite_dev_server = "http://localhost:8000" }
190
+
191
+ # Attributes set in the first call must survive the second call.
192
+ expect(Ruact.config.suspense_timeout).to eq(7.5)
193
+ expect(Ruact.config.strict_serialization).to be(true)
194
+ expect(Ruact.config.vite_dev_server).to eq("http://localhost:8000")
195
+ end
196
+
197
+ it "intermediate readers inside the second configure block see the OLD frozen config (AC3)" do
198
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
199
+ old_config = Ruact.config
200
+
201
+ observed_inside_block = nil
202
+ observed_timeout = nil
203
+
204
+ Ruact.configure do |draft|
205
+ # While the block runs, Ruact.config still resolves to the OLD frozen
206
+ # config; the draft is a separate object that is not yet swapped in.
207
+ observed_inside_block = Ruact.config
208
+ observed_timeout = Ruact.config.suspense_timeout
209
+ draft.suspense_timeout = 99.0
210
+ end
211
+
212
+ expect(observed_inside_block).to equal(old_config)
213
+ expect(observed_timeout).to eq(5.0)
214
+ expect(Ruact.config).not_to equal(old_config)
215
+ expect(Ruact.config.suspense_timeout).to eq(99.0)
216
+ end
217
+ end
218
+
219
+ describe "re-configuration warning" do
220
+ let(:fake_logger) { instance_double(::Logger, warn: nil) }
221
+
222
+ # Override the parent before-hook with a per-example fake we can assert on.
223
+ before { Rails.logger = fake_logger }
224
+
225
+ it "does NOT warn on the first Ruact.configure call (AC3)" do
226
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
227
+ expect(fake_logger).not_to have_received(:warn)
228
+ end
229
+
230
+ it "warns on the second Ruact.configure call (AC3)" do
231
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
232
+ Ruact.configure { |c| c.suspense_timeout = 9.0 }
233
+
234
+ expect(fake_logger).to have_received(:warn).with(
235
+ a_string_matching(/\[ruact\] Ruact\.configure called after boot at .+:\d+/)
236
+ )
237
+ end
238
+
239
+ it "warns on Ruact.configure that follows default Ruact.config first access (AC3, F2 fix)" do
240
+ # Default first-access publishes the frozen-default Configuration; that
241
+ # publication counts as boot, so the next configure call must warn.
242
+ Ruact.config
243
+ Ruact.configure { |c| c.suspense_timeout = 9.0 }
244
+
245
+ expect(fake_logger).to have_received(:warn).with(
246
+ a_string_matching(/\[ruact\] Ruact\.configure called after boot/)
247
+ )
248
+ end
249
+ end
250
+
251
+ describe "Ruact::ConfigurationError" do
252
+ it "is a subclass of Ruact::Error (AC6)" do
253
+ expect(Ruact::ConfigurationError.ancestors).to include(Ruact::Error)
254
+ end
255
+ end
256
+
257
+ describe "boot-time defaults" do
258
+ it "freezes the default Configuration on first access (AC5)" do
259
+ # No Ruact.configure call — first access goes through Ruact.config directly.
260
+ expect(Ruact.config).to be_frozen
261
+ end
262
+
263
+ it "applies documented defaults when no configure block runs (AC5)" do
264
+ config = Ruact.config
265
+
266
+ expect(config.manifest_path).to be_nil
267
+ expect(config.strict_serialization).to be(false)
268
+ expect(config.suspense_timeout).to eq(5.0)
269
+ expect(config.vite_dev_server).to eq("http://localhost:5173")
270
+ expect(config.current_user_resolver).to be_nil
271
+ end
272
+
273
+ it "rejects post-boot mutation against the default-frozen instance (AC5)" do
274
+ expect { Ruact.config.suspense_timeout = 10.0 }
275
+ .to raise_error(Ruact::ConfigurationError)
276
+ end
277
+ end
278
+
279
+ describe "Story 8.3 — current_user_resolver attribute", :story_8_3 do
280
+ it "defaults to nil so apps without standalone actions never get a phantom resolver" do
281
+ expect(Ruact.config.current_user_resolver).to be_nil
282
+ end
283
+
284
+ it "accepts a lambda inside Ruact.configure and exposes it after publication" do
285
+ resolver = ->(env) { env["warden"]&.user }
286
+ Ruact.configure { |c| c.current_user_resolver = resolver }
287
+ expect(Ruact.config.current_user_resolver).to be(resolver)
288
+ end
289
+
290
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
291
+ Ruact.configure { |c| c.current_user_resolver = ->(_env) {} }
292
+ expect { Ruact.config.current_user_resolver = ->(_env) { "other" } }
293
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#current_user_resolver/)
294
+ end
295
+
296
+ it "is carried across atomic re-configuration (template clone)" do
297
+ first = ->(_env) { :first }
298
+ Ruact.configure { |c| c.current_user_resolver = first }
299
+ Ruact.configure { |c| c.suspense_timeout = 6.0 } # untouched
300
+ expect(Ruact.config.current_user_resolver).to be(first)
301
+ end
302
+
303
+ it "deep-freezes the resolver lambda at publication time (Story 8.3 review R6) — " \
304
+ "identity is preserved AND `frozen?` reports true" do
305
+ resolver = ->(_env) {}
306
+ Ruact.configure { |c| c.current_user_resolver = resolver }
307
+ expect(Ruact.config.current_user_resolver).to be(resolver)
308
+ expect(Ruact.config.current_user_resolver).to be_frozen
309
+ end
310
+ end
311
+
312
+ describe "Story 8.5 — max_upload_bytes attribute", :story_8_5 do
313
+ it "defaults to 10 MB (10 * 1024 * 1024 bytes)" do
314
+ expect(Ruact.config.max_upload_bytes).to eq(10 * 1024 * 1024)
315
+ end
316
+
317
+ it "accepts an Integer inside Ruact.configure" do
318
+ Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
319
+ expect(Ruact.config.max_upload_bytes).to eq(25 * 1024 * 1024)
320
+ end
321
+
322
+ it "accepts nil to disable the gem-side guard" do
323
+ Ruact.configure { |c| c.max_upload_bytes = nil }
324
+ expect(Ruact.config.max_upload_bytes).to be_nil
325
+ end
326
+
327
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
328
+ Ruact.configure { |c| c.max_upload_bytes = 5 * 1024 * 1024 }
329
+ expect { Ruact.config.max_upload_bytes = 9_000_000 }
330
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#max_upload_bytes/)
331
+ end
332
+
333
+ it "is carried across atomic re-configuration (template clone)" do
334
+ Ruact.configure { |c| c.max_upload_bytes = 7 * 1024 * 1024 }
335
+ Ruact.configure { |c| c.suspense_timeout = 6.0 }
336
+ expect(Ruact.config.max_upload_bytes).to eq(7 * 1024 * 1024)
337
+ end
338
+
339
+ describe "writer-time validation (review patch)" do
340
+ it "accepts 0 (a degenerate but legal cap that rejects every multipart/urlencoded request)" do
341
+ expect { Ruact.configure { |c| c.max_upload_bytes = 0 } }.not_to raise_error
342
+ expect(Ruact.config.max_upload_bytes).to eq(0)
343
+ end
344
+
345
+ it "rejects negative Integer with ConfigurationError" do
346
+ expect { Ruact.configure { |c| c.max_upload_bytes = -1 } }
347
+ .to raise_error(Ruact::ConfigurationError, /must be nil or a non-negative Integer/)
348
+ end
349
+
350
+ it "rejects String with ConfigurationError naming the offending value + class" do
351
+ expect { Ruact.configure { |c| c.max_upload_bytes = "10485760" } }
352
+ .to raise_error(Ruact::ConfigurationError, /got "10485760" \(String\)/)
353
+ end
354
+
355
+ it "rejects Float with ConfigurationError" do
356
+ expect { Ruact.configure { |c| c.max_upload_bytes = 1024.0 } }
357
+ .to raise_error(Ruact::ConfigurationError, /must be nil or a non-negative Integer/)
358
+ end
359
+
360
+ it "rejects Symbol with ConfigurationError" do
361
+ expect { Ruact.configure { |c| c.max_upload_bytes = :unlimited } }
362
+ .to raise_error(Ruact::ConfigurationError, /must be nil or a non-negative Integer/)
363
+ end
364
+
365
+ it "rejects true/false with ConfigurationError" do
366
+ expect { Ruact.configure { |c| c.max_upload_bytes = true } }
367
+ .to raise_error(Ruact::ConfigurationError, /must be nil or a non-negative Integer/)
368
+ end
369
+ end
370
+ end
371
+
372
+ describe "Story 8.4 — dev_error_payload_enabled attribute", :story_8_4 do
373
+ it "defaults to nil so the endpoint controller can resolve to Rails env at request time" do
374
+ expect(Ruact.config.dev_error_payload_enabled).to be_nil
375
+ end
376
+
377
+ it "accepts true inside Ruact.configure and exposes it after publication" do
378
+ Ruact.configure { |c| c.dev_error_payload_enabled = true }
379
+ expect(Ruact.config.dev_error_payload_enabled).to be(true)
380
+ end
381
+
382
+ it "accepts false inside Ruact.configure and exposes it after publication" do
383
+ Ruact.configure { |c| c.dev_error_payload_enabled = false }
384
+ expect(Ruact.config.dev_error_payload_enabled).to be(false)
385
+ end
386
+
387
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
388
+ Ruact.configure { |c| c.dev_error_payload_enabled = true }
389
+ expect { Ruact.config.dev_error_payload_enabled = false }
390
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#dev_error_payload_enabled/)
391
+ end
392
+
393
+ it "is carried across atomic re-configuration (template clone)" do
394
+ Ruact.configure { |c| c.dev_error_payload_enabled = false }
395
+ Ruact.configure { |c| c.suspense_timeout = 6.0 }
396
+ expect(Ruact.config.dev_error_payload_enabled).to be(false)
397
+ end
398
+ end
399
+
400
+ describe "Story 9.4 — query_route_prefix attribute", :story_9_4 do
401
+ it "defaults to \"/q\"" do
402
+ expect(Ruact.config.query_route_prefix).to eq("/q")
403
+ end
404
+
405
+ it "accepts a custom prefix inside Ruact.configure" do
406
+ Ruact.configure { |c| c.query_route_prefix = "/api/queries" }
407
+ expect(Ruact.config.query_route_prefix).to eq("/api/queries")
408
+ end
409
+
410
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
411
+ Ruact.configure { |c| c.query_route_prefix = "/q" }
412
+ expect { Ruact.config.query_route_prefix = "/other" }
413
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#query_route_prefix/)
414
+ end
415
+
416
+ it "is carried across atomic re-configuration (template clone)" do
417
+ Ruact.configure { |c| c.query_route_prefix = "/internal/q" }
418
+ Ruact.configure { |c| c.suspense_timeout = 6.0 }
419
+ expect(Ruact.config.query_route_prefix).to eq("/internal/q")
420
+ end
421
+
422
+ describe "writer-time validation" do
423
+ it "rejects a non-String with ConfigurationError naming the offending value + class" do
424
+ expect { Ruact.configure { |c| c.query_route_prefix = :q } }
425
+ .to raise_error(Ruact::ConfigurationError, /got :q \(Symbol\)/)
426
+ end
427
+
428
+ it "rejects a prefix that does not start with \"/\"" do
429
+ expect { Ruact.configure { |c| c.query_route_prefix = "q" } }
430
+ .to raise_error(Ruact::ConfigurationError, %r{must be a String starting with "/"})
431
+ end
432
+
433
+ it "rejects a prefix with a trailing slash (the macro joins with \"/\")" do
434
+ expect { Ruact.configure { |c| c.query_route_prefix = "/q/" } }
435
+ .to raise_error(Ruact::ConfigurationError, %r{must not end with "/"})
436
+ end
437
+
438
+ it "rejects nil" do
439
+ expect { Ruact.configure { |c| c.query_route_prefix = nil } }
440
+ .to raise_error(Ruact::ConfigurationError, %r{must be a String starting with "/"})
441
+ end
442
+ end
443
+ end
444
+
445
+ describe "Story 9.4 — query_parent_controller attribute", :story_9_4 do
446
+ it "defaults to \"ApplicationController\"" do
447
+ expect(Ruact.config.query_parent_controller).to eq("ApplicationController")
448
+ end
449
+
450
+ it "accepts a custom controller name inside Ruact.configure" do
451
+ Ruact.configure { |c| c.query_parent_controller = "Api::BaseController" }
452
+ expect(Ruact.config.query_parent_controller).to eq("Api::BaseController")
453
+ end
454
+
455
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
456
+ Ruact.configure { |c| c.query_parent_controller = "ApplicationController" }
457
+ expect { Ruact.config.query_parent_controller = "Other" }
458
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#query_parent_controller/)
459
+ end
460
+
461
+ it "is carried across atomic re-configuration (template clone)" do
462
+ Ruact.configure { |c| c.query_parent_controller = "Api::BaseController" }
463
+ Ruact.configure { |c| c.suspense_timeout = 6.0 }
464
+ expect(Ruact.config.query_parent_controller).to eq("Api::BaseController")
465
+ end
466
+
467
+ describe "writer-time validation" do
468
+ it "rejects a Class with ConfigurationError (the name is constantized lazily at route-draw time)" do
469
+ expect { Ruact.configure { |c| c.query_parent_controller = Class.new } }
470
+ .to raise_error(Ruact::ConfigurationError, /must be a non-empty String/)
471
+ end
472
+
473
+ it "rejects an empty String" do
474
+ expect { Ruact.configure { |c| c.query_parent_controller = "" } }
475
+ .to raise_error(Ruact::ConfigurationError, /must be a non-empty String/)
476
+ end
477
+
478
+ it "rejects nil" do
479
+ expect { Ruact.configure { |c| c.query_parent_controller = nil } }
480
+ .to raise_error(Ruact::ConfigurationError, /must be a non-empty String/)
481
+ end
482
+ end
483
+ end
484
+
485
+ describe "error message includes caller location" do
486
+ it "names the file:line of the offending mutation (AC2.2)" do
487
+ Ruact.configure { |c| c.suspense_timeout = 5.0 }
488
+
489
+ # Capture the file:line of the next line via __FILE__ / __LINE__.
490
+ expected_path = __FILE__
491
+ expected_line = __LINE__ + 2
492
+ expect do
493
+ Ruact.config.suspense_timeout = 99.0
494
+ end.to raise_error(
495
+ Ruact::ConfigurationError,
496
+ a_string_matching(/Attempted at: #{Regexp.escape(expected_path)}:#{expected_line}/)
497
+ )
498
+ end
499
+ end
500
+ end
501
+ end