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.
- 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 +3 -2
- 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 +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- 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
|