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
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -198,6 +198,84 @@ RSpec.describe Ruact::Doctor do
|
|
|
198
198
|
end
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# --- check_legacy_constant ---
|
|
202
|
+
|
|
203
|
+
describe "#check_legacy_constant (Story 5.1)" do
|
|
204
|
+
subject(:doctor) { described_class.new }
|
|
205
|
+
|
|
206
|
+
# Built via Array#join so this spec file passes the gem-CI
|
|
207
|
+
# `name-propagation` guard without an exclusion (Story 5.1 review F4).
|
|
208
|
+
let(:legacy_const) { %w[Rails Rsc].join }
|
|
209
|
+
let(:legacy_gem) { %w[rails rsc].join("_") }
|
|
210
|
+
|
|
211
|
+
def make_initializer(content, filename: "ruact.rb")
|
|
212
|
+
dir = tmpdir.join("config", "initializers")
|
|
213
|
+
FileUtils.mkdir_p(dir)
|
|
214
|
+
File.write(dir.join(filename), content)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def make_app_file(content, path:)
|
|
218
|
+
dir = tmpdir.join("app", File.dirname(path))
|
|
219
|
+
FileUtils.mkdir_p(dir)
|
|
220
|
+
File.write(tmpdir.join("app", path), content)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
context "when no legacy references exist" do
|
|
224
|
+
it "returns :pass" do
|
|
225
|
+
status, = doctor.send(:check_legacy_constant)
|
|
226
|
+
expect(status).to eq(:pass)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
context "when an initializer references the legacy constant" do
|
|
231
|
+
before { make_initializer("#{legacy_const}.configure do |c|\n c.foo = 1\nend\n") }
|
|
232
|
+
|
|
233
|
+
it "returns :fail" do
|
|
234
|
+
status, = doctor.send(:check_legacy_constant)
|
|
235
|
+
expect(status).to eq(:fail)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "message names the file:line and instructs the rename" do
|
|
239
|
+
_, msg = doctor.send(:check_legacy_constant)
|
|
240
|
+
expect(msg).to include("ruact.rb:1")
|
|
241
|
+
expect(msg).to include("Replace `#{legacy_const}` with `Ruact`")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "message includes the rename documentation link (AC5)" do
|
|
245
|
+
_, msg = doctor.send(:check_legacy_constant)
|
|
246
|
+
expect(msg).to include("https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
context "when an app file requires the legacy gem name" do
|
|
251
|
+
before { make_app_file("require \"#{legacy_gem}\"\n", path: "models/foo.rb") }
|
|
252
|
+
|
|
253
|
+
it "returns :fail" do
|
|
254
|
+
status, = doctor.send(:check_legacy_constant)
|
|
255
|
+
expect(status).to eq(:fail)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
context "when only the modern Ruact constant is referenced" do
|
|
260
|
+
before { make_initializer("Ruact.configure { |c| c.foo = 1 }\n") }
|
|
261
|
+
|
|
262
|
+
it "returns :pass" do
|
|
263
|
+
status, = doctor.send(:check_legacy_constant)
|
|
264
|
+
expect(status).to eq(:pass)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
context "when a word coincidentally contains the legacy substring" do
|
|
269
|
+
# e.g. "TrailsRsce" should not trigger the regex (boundary check).
|
|
270
|
+
before { make_initializer("# documenting TrailsRsce_engine here\n") }
|
|
271
|
+
|
|
272
|
+
it "returns :pass" do
|
|
273
|
+
status, = doctor.send(:check_legacy_constant)
|
|
274
|
+
expect(status).to eq(:pass)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
201
279
|
# --- run / .run ---
|
|
202
280
|
|
|
203
281
|
describe ".run / #run (AC#1, #7)" do
|
|
@@ -231,4 +309,44 @@ RSpec.describe Ruact::Doctor do
|
|
|
231
309
|
end
|
|
232
310
|
end
|
|
233
311
|
end
|
|
312
|
+
|
|
313
|
+
describe "Rake task definition (Story 5.12)", :story_5_12 do
|
|
314
|
+
# Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
|
|
315
|
+
# task table is isolated from any other spec that may have loaded tasks.
|
|
316
|
+
# Asserts the new namespace is discoverable AND the legacy namespace is
|
|
317
|
+
# gone — guards against a partial rename (e.g. file renamed but the
|
|
318
|
+
# namespace inside left as :rsc, or vice-versa).
|
|
319
|
+
around do |example|
|
|
320
|
+
require "rake"
|
|
321
|
+
prev = Rake.application
|
|
322
|
+
Rake.application = Rake::Application.new
|
|
323
|
+
Rake.application.add_loader("rake", Rake::DefaultLoader.new)
|
|
324
|
+
Rake.application.add_loader("rb", Rake::DefaultLoader.new)
|
|
325
|
+
load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
|
|
326
|
+
example.run
|
|
327
|
+
ensure
|
|
328
|
+
Rake.application = prev
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
let(:rake_app) { Rake.application }
|
|
332
|
+
|
|
333
|
+
it "defines Rake::Task['ruact:doctor']" do
|
|
334
|
+
expect(rake_app.lookup("ruact:doctor")).not_to be_nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "does NOT define the legacy Rake task under the pre-Story-5.12 namespace" do
|
|
338
|
+
# Build the legacy task name from fragments so this spec file stays
|
|
339
|
+
# grep-clean under the Story 5.12 CI guard (which rejects the literal
|
|
340
|
+
# `<legacy>:doctor` substring anywhere in tracked files).
|
|
341
|
+
legacy_task = %w[rsc doctor].join(":")
|
|
342
|
+
expect(rake_app.lookup(legacy_task)).to be_nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it "the ruact:doctor task action invokes Ruact::Doctor.run" do
|
|
346
|
+
task = rake_app.lookup("ruact:doctor")
|
|
347
|
+
expect(task).not_to be_nil
|
|
348
|
+
# actions is a list of Procs; just assert at least one is attached
|
|
349
|
+
expect(task.actions).not_to be_empty
|
|
350
|
+
end
|
|
351
|
+
end
|
|
234
352
|
end
|
|
@@ -24,13 +24,13 @@ module Ruact
|
|
|
24
24
|
it "applies ErbPreprocessor.transform to source before calling super" do
|
|
25
25
|
source = "<LikeButton postId={1} />"
|
|
26
26
|
result = handler.call(fake_template, source)
|
|
27
|
-
expect(result).to include("
|
|
27
|
+
expect(result).to include("__ruact_component__")
|
|
28
28
|
expect(result).to include('"LikeButton"')
|
|
29
29
|
expect(result).not_to include("<LikeButton")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
it "passes source unchanged when no PascalCase tags present (fast-path)" do
|
|
33
|
-
source = "<div class=\"hello\"><p>No
|
|
33
|
+
source = "<div class=\"hello\"><p>No ruact here</p></div>"
|
|
34
34
|
result = handler.call(fake_template, source)
|
|
35
35
|
expect(result).to eq(source)
|
|
36
36
|
end
|
|
@@ -45,7 +45,7 @@ module Ruact
|
|
|
45
45
|
it "transforms Suspense tags correctly" do
|
|
46
46
|
source = "<Suspense fallback=\"Loading\"><PostCard /></Suspense>"
|
|
47
47
|
result = handler.call(fake_template, source)
|
|
48
|
-
expect(result).to include("
|
|
48
|
+
expect(result).to include("__ruact_component__")
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
@@ -8,19 +8,19 @@ module Ruact
|
|
|
8
8
|
|
|
9
9
|
describe "self-closing tags" do
|
|
10
10
|
it "transforms a self-closing tag with no props" do
|
|
11
|
-
expect(transform.call("<Button />")).to eq(%(<%=
|
|
11
|
+
expect(transform.call("<Button />")).to eq(%(<%= __ruact_component__("Button", {}) %>))
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
it "transforms a self-closing tag with props" do
|
|
15
15
|
result = transform.call("<LikeButton postId={@post.id} initialCount={5} />")
|
|
16
|
-
expect(result).to eq(%(<%=
|
|
16
|
+
expect(result).to eq(%(<%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
describe "opening tags" do
|
|
21
21
|
it "transforms an opening tag with props" do
|
|
22
22
|
result = transform.call("<Dialog open={true}>")
|
|
23
|
-
expect(result).to eq(%(<%=
|
|
23
|
+
expect(result).to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
@@ -39,7 +39,7 @@ module Ruact
|
|
|
39
39
|
describe "complex prop expressions" do
|
|
40
40
|
it "handles nested braces in a prop value" do
|
|
41
41
|
result = transform.call("<Select options={Category.all.map { |c| c.id }} />")
|
|
42
|
-
expect(result).to eq(%(<%=
|
|
42
|
+
expect(result).to eq(%(<%= __ruact_component__("Select", { "options" => Category.all.map { |c| c.id } }) %>))
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -65,8 +65,8 @@ module Ruact
|
|
|
65
65
|
it "transforms multiple components in the same string" do
|
|
66
66
|
source = '<Button /> and <Badge label={"hello"} />'
|
|
67
67
|
result = transform.call(source)
|
|
68
|
-
expect(result).to match(/
|
|
69
|
-
expect(result).to match(/
|
|
68
|
+
expect(result).to match(/__ruact_component__\("Button"/)
|
|
69
|
+
expect(result).to match(/__ruact_component__\("Badge"/)
|
|
70
70
|
expect(result).to match(/"hello"/)
|
|
71
71
|
end
|
|
72
72
|
end
|
|
@@ -82,7 +82,7 @@ module Ruact
|
|
|
82
82
|
result = transform.call(source)
|
|
83
83
|
expect(result).to match(/<div class="container">/)
|
|
84
84
|
expect(result).to match(%r{<h1>Hello</h1>})
|
|
85
|
-
expect(result).to match(/
|
|
85
|
+
expect(result).to match(/__ruact_component__\("LikeButton"/)
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
end
|
data/spec/ruact/errors_spec.rb
CHANGED
|
@@ -39,5 +39,100 @@ module Ruact
|
|
|
39
39
|
expect { raise PreprocessorError, "test" }.to raise_error(Error)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
describe "Ruact::ConfigurationError" do
|
|
44
|
+
it "is a subclass of Ruact::Error" do
|
|
45
|
+
expect(ConfigurationError.ancestors).to include(Error)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "can be raised and rescued as Ruact::Error" do
|
|
49
|
+
expect { raise ConfigurationError, "test" }.to raise_error(Error)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "Ruact::HtmlConverterError" do
|
|
54
|
+
it "is a subclass of Ruact::Error" do
|
|
55
|
+
expect(HtmlConverterError.ancestors).to include(Error)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "can be raised and rescued as Ruact::Error" do
|
|
59
|
+
expect { raise HtmlConverterError, "test" }.to raise_error(Error)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe "Ruact::CurrentUserNotConfiguredError", :story_8_3 do
|
|
64
|
+
it "is a subclass of Ruact::Error" do
|
|
65
|
+
expect(CurrentUserNotConfiguredError.ancestors).to include(Error)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "carries a default message that names BOTH Devise and hand-rolled-session worked examples" do
|
|
69
|
+
message = CurrentUserNotConfiguredError.new.message
|
|
70
|
+
expect(message).to include("Ruact.current_user requires Ruact.config.current_user_resolver to be set")
|
|
71
|
+
expect(message).to include("Devise")
|
|
72
|
+
expect(message).to include("env['warden']")
|
|
73
|
+
expect(message).to include("hand-rolled session")
|
|
74
|
+
expect(message).to include("rack.session")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "accepts a custom message" do
|
|
78
|
+
expect(CurrentUserNotConfiguredError.new("explicit").message).to eq("explicit")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe "Ruact::UploadTooLargeError", :story_8_5 do
|
|
83
|
+
it "is a subclass of Ruact::Error (so Story 8.4 rescue_from StandardError catches it)" do
|
|
84
|
+
expect(UploadTooLargeError.ancestors).to include(Error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "carries received_bytes and limit_bytes attr_readers" do
|
|
88
|
+
error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
|
|
89
|
+
expect(error.received_bytes).to eq(11_534_336)
|
|
90
|
+
expect(error.limit_bytes).to eq(10_485_760)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "synthesises a default message that includes both numbers" do
|
|
94
|
+
error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
|
|
95
|
+
expect(error.message).to include("received_bytes=11534336")
|
|
96
|
+
expect(error.message).to include("limit_bytes=10485760")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "honours an explicit custom message" do
|
|
100
|
+
error = UploadTooLargeError.new(received_bytes: 1, limit_bytes: 2, message: "custom")
|
|
101
|
+
expect(error.message).to eq("custom")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "can be rescued as Ruact::Error and StandardError" do
|
|
105
|
+
expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
|
|
106
|
+
.to raise_error(Error)
|
|
107
|
+
expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
|
|
108
|
+
.to raise_error(StandardError)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe "Ruact::ActionError", :story_8_3 do
|
|
113
|
+
it "is a subclass of Ruact::Error" do
|
|
114
|
+
expect(ActionError.ancestors).to include(Error)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "carries status + body so the dispatcher can render without `render` access" do
|
|
118
|
+
error = ActionError.new(status: 422, body: { error: "invalid" })
|
|
119
|
+
expect(error.status).to eq(422)
|
|
120
|
+
expect(error.body).to eq(error: "invalid")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "accepts a Symbol status" do
|
|
124
|
+
error = ActionError.new(status: :unprocessable_entity, body: nil)
|
|
125
|
+
expect(error.status).to eq(:unprocessable_entity)
|
|
126
|
+
expect(error.body).to be_nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "synthesises a legible message when none is given" do
|
|
130
|
+
expect(ActionError.new(status: 401, body: nil).message).to include("status=401")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "honours an explicit custom message" do
|
|
134
|
+
expect(ActionError.new(status: 500, body: {}, message: "explicit").message).to eq("explicit")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
42
137
|
end
|
|
43
138
|
end
|
|
@@ -31,7 +31,7 @@ module Ruact
|
|
|
31
31
|
it "root row always has id 0" do
|
|
32
32
|
element = ReactElement.new(type: "p")
|
|
33
33
|
output = described_class.render(element, empty_manifest)
|
|
34
|
-
expect(output.
|
|
34
|
+
expect(Ruact::Spec::FlightWireParser.parse(output).last).to include(id: 0, class: :model)
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -61,9 +61,19 @@ module Ruact
|
|
|
61
61
|
let(:fallback) { ReactElement.new(type: "span") }
|
|
62
62
|
let(:inner) { ReactElement.new(type: "div") }
|
|
63
63
|
|
|
64
|
+
# Configuration is frozen post-Story 7.3, so RSpec mocks cannot proxy it.
|
|
65
|
+
# Configure via Ruact.configure and reset around each example.
|
|
66
|
+
around do |example|
|
|
67
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
68
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
69
|
+
example.run
|
|
70
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
71
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
context "when deferred delay exceeds suspense_timeout (streaming: true)" do
|
|
65
75
|
before do
|
|
66
|
-
|
|
76
|
+
Ruact.configure { |c| c.suspense_timeout = 1.0 }
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
it "emits an E-type error row instead of the model row" do
|
|
@@ -76,6 +86,7 @@ module Ruact
|
|
|
76
86
|
suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
|
|
77
87
|
rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
|
|
78
88
|
error_row = rows.find { |r| r.include?(":E") }
|
|
89
|
+
# Error message string is the contract — see Story 7.6 Decision E.
|
|
79
90
|
expect(error_row).to include("Suspense timeout exceeded")
|
|
80
91
|
end
|
|
81
92
|
|
|
@@ -90,7 +101,7 @@ module Ruact
|
|
|
90
101
|
|
|
91
102
|
context "when deferred delay is within suspense_timeout (streaming: true)" do
|
|
92
103
|
before do
|
|
93
|
-
|
|
104
|
+
Ruact.configure { |c| c.suspense_timeout = 10.0 }
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
it "emits a model row (not an error row) and no E row" do
|
|
@@ -16,87 +16,32 @@ module Ruact
|
|
|
16
16
|
|
|
17
17
|
let(:bundler) { StubBundlerConfig.new }
|
|
18
18
|
|
|
19
|
-
# --- Primitives ---
|
|
20
|
-
|
|
21
|
-
describe "strings" do
|
|
22
|
-
it "serializes a plain string" do
|
|
23
|
-
expect(render.call("hello")).to eq("0:\"hello\"\n")
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it "escapes strings starting with $" do
|
|
27
|
-
expect(render.call("$danger")).to eq("0:\"$$danger\"\n")
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
describe "numbers" do
|
|
32
|
-
it "serializes an integer" do
|
|
33
|
-
expect(render.call(42)).to eq("0:42\n")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
it "serializes a bigint (> MAX_SAFE_INTEGER) as $n<decimal>" do
|
|
37
|
-
big = 9_007_199_254_740_992
|
|
38
|
-
expect(render.call(big)).to eq("0:\"$n#{big}\"\n")
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
it "serializes Float::NAN" do
|
|
42
|
-
expect(render.call(Float::NAN)).to eq("0:\"$NaN\"\n")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it "serializes Float::INFINITY" do
|
|
46
|
-
expect(render.call(Float::INFINITY)).to eq("0:\"$Infinity\"\n")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
it "serializes -Float::INFINITY" do
|
|
50
|
-
expect(render.call(-Float::INFINITY)).to eq("0:\"$-Infinity\"\n")
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
describe "nil/boolean" do
|
|
55
|
-
it "serializes nil as null" do
|
|
56
|
-
expect(render.call(nil)).to eq("0:null\n")
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
it "serializes true" do
|
|
60
|
-
expect(render.call(true)).to eq("0:true\n")
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
it "serializes false" do
|
|
64
|
-
expect(render.call(false)).to eq("0:false\n")
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
describe "special symbols" do
|
|
69
|
-
it "serializes :undefined as $undefined" do
|
|
70
|
-
expect(render.call(:undefined)).to eq("0:\"$undefined\"\n")
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# --- Collections ---
|
|
75
|
-
|
|
76
|
-
describe "hash" do
|
|
77
|
-
it "serializes a hash with symbol keys" do
|
|
78
|
-
out = render.call({ greeting: "hello", count: 42 })
|
|
79
|
-
expect(out).to eq("0:{\"greeting\":\"hello\",\"count\":42}\n")
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
describe "array" do
|
|
84
|
-
it "serializes a plain array" do
|
|
85
|
-
expect(render.call([1, 2, 3])).to eq("0:[1,2,3]\n")
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
19
|
# --- React Element (DOM) ---
|
|
20
|
+
#
|
|
21
|
+
# Primitive scalar/collection inputs (nil, booleans, integer, float, string,
|
|
22
|
+
# `$`-escaped string, hash, array) are guarded by the fixture-mode block at
|
|
23
|
+
# the bottom of this file. The cases below assert on the *parsed shape* of
|
|
24
|
+
# ReactElement payloads — fixture mode is overkill for them since the
|
|
25
|
+
# invariant under test is "the parsed tuple is `["$", <type>, <key>, <props>]`",
|
|
26
|
+
# not the literal byte sequence.
|
|
90
27
|
|
|
91
28
|
describe "ReactElement" do
|
|
92
29
|
it "serializes a DOM element" do
|
|
93
30
|
el = ReactElement.new(type: "div", props: { className: "box", children: "hi" })
|
|
94
|
-
|
|
31
|
+
expected = [
|
|
32
|
+
{ id: 0, class: :model,
|
|
33
|
+
payload: ["$", "div", nil, { "className" => "box", "children" => "hi" }] }
|
|
34
|
+
]
|
|
35
|
+
expect(render.call(el)).to match_flight_structure(expected)
|
|
95
36
|
end
|
|
96
37
|
|
|
97
38
|
it "serializes a DOM element with a key" do
|
|
98
39
|
el = ReactElement.new(type: "li", key: "item-1", props: { children: "one" })
|
|
99
|
-
|
|
40
|
+
expected = [
|
|
41
|
+
{ id: 0, class: :model,
|
|
42
|
+
payload: ["$", "li", "item-1", { "children" => "one" }] }
|
|
43
|
+
]
|
|
44
|
+
expect(render.call(el)).to match_flight_structure(expected)
|
|
100
45
|
end
|
|
101
46
|
end
|
|
102
47
|
|
|
@@ -106,11 +51,18 @@ module Ruact
|
|
|
106
51
|
it "emits an I row for the import and references $L1 in root" do
|
|
107
52
|
ref = ClientReference.new(module_id: "./LikeButton", export_name: "LikeButton")
|
|
108
53
|
el = ReactElement.new(type: ref, props: { postId: 1, initialCount: 5 })
|
|
109
|
-
out = render.call(el)
|
|
110
54
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
55
|
+
# Story 7.6 Decision B: collapse the original sibling regex pair plus
|
|
56
|
+
# the `out.index("1:I") < out.index("0:[")` ordering check into a
|
|
57
|
+
# single structural assertion. Non-import rows compare positionally,
|
|
58
|
+
# so the import row in expected position 0 + the model row in
|
|
59
|
+
# expected position 1 encode the ordering invariant.
|
|
60
|
+
expected = [
|
|
61
|
+
{ id: 1, class: :import, payload: ["./LikeButton", "LikeButton"] },
|
|
62
|
+
{ id: 0, class: :model,
|
|
63
|
+
payload: ["$", "$L1", nil, { "postId" => 1, "initialCount" => 5 }] }
|
|
64
|
+
]
|
|
65
|
+
expect(render.call(el)).to match_flight_structure(expected)
|
|
114
66
|
end
|
|
115
67
|
|
|
116
68
|
it "deduplicates: same ClientReference object emits only one I row" do
|
|
@@ -127,10 +79,23 @@ module Ruact
|
|
|
127
79
|
# --- Error handling ---
|
|
128
80
|
|
|
129
81
|
describe "unsupported type (AC#2)" do
|
|
82
|
+
# Build a class with no `as_json` method. ActiveSupport adds Object#as_json
|
|
83
|
+
# when Rails is loaded (Story 7.9), so Object.new no longer triggers the
|
|
84
|
+
# "no as_json" branch; this class explicitly undefines it.
|
|
85
|
+
let(:no_as_json_class) do
|
|
86
|
+
Class.new do
|
|
87
|
+
undef_method :as_json if method_defined?(:as_json) || private_method_defined?(:as_json)
|
|
88
|
+
|
|
89
|
+
def self.name
|
|
90
|
+
"UnsupportedType"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
130
95
|
it "raises SerializationError with class name and Serializable hint" do
|
|
131
|
-
expect { render.call(
|
|
132
|
-
.to raise_error(Ruact::SerializationError, /
|
|
133
|
-
expect { render.call(
|
|
96
|
+
expect { render.call(no_as_json_class.new) }
|
|
97
|
+
.to raise_error(Ruact::SerializationError, /UnsupportedType/)
|
|
98
|
+
expect { render.call(no_as_json_class.new) }
|
|
134
99
|
.to raise_error(Ruact::SerializationError, /include Ruact::Serializable/)
|
|
135
100
|
end
|
|
136
101
|
end
|
|
@@ -150,7 +115,18 @@ module Ruact
|
|
|
150
115
|
end.new
|
|
151
116
|
end
|
|
152
117
|
|
|
153
|
-
let(:model_without_as_json)
|
|
118
|
+
let(:model_without_as_json) do
|
|
119
|
+
# ActiveSupport adds Object#as_json when Rails is loaded (Story 7.9), so
|
|
120
|
+
# Object.new no longer represents "no as_json defined". Use a class that
|
|
121
|
+
# explicitly undefines it instead.
|
|
122
|
+
Class.new do
|
|
123
|
+
undef_method :as_json if method_defined?(:as_json) || private_method_defined?(:as_json)
|
|
124
|
+
|
|
125
|
+
def self.name
|
|
126
|
+
"ModelWithoutAsJson"
|
|
127
|
+
end
|
|
128
|
+
end.new
|
|
129
|
+
end
|
|
154
130
|
|
|
155
131
|
context "with strict_serialization: false (default)" do
|
|
156
132
|
let(:render_loose) do
|
|
@@ -159,8 +135,10 @@ module Ruact
|
|
|
159
135
|
end
|
|
160
136
|
|
|
161
137
|
it "serializes via as_json, returning a hash (AC#3)" do
|
|
162
|
-
expect(render_loose.call(model_with_as_json)).to
|
|
163
|
-
|
|
138
|
+
expect(render_loose.call(model_with_as_json)).to include_flight_row(
|
|
139
|
+
class: :model,
|
|
140
|
+
payload: hash_including("id" => 1, "name" => "Alice")
|
|
141
|
+
)
|
|
164
142
|
end
|
|
165
143
|
|
|
166
144
|
it "calls on_as_json_warning with class name and attribute list (AC#1, #3)" do
|
|
@@ -208,7 +186,7 @@ module Ruact
|
|
|
208
186
|
it "serializes the array without crashing" do
|
|
209
187
|
b = StubBundlerConfig.new
|
|
210
188
|
output = Renderer.render(model_returning_array, b, strict_serialization: false)
|
|
211
|
-
expect(output).to
|
|
189
|
+
expect(output).to include_flight_row(class: :model, payload: [1, 2, 3])
|
|
212
190
|
end
|
|
213
191
|
end
|
|
214
192
|
|
|
@@ -232,6 +210,40 @@ module Ruact
|
|
|
232
210
|
end
|
|
233
211
|
end
|
|
234
212
|
|
|
213
|
+
# --- Story 7.7 regression suite — as_json returning self message shape ---
|
|
214
|
+
#
|
|
215
|
+
# The "with as_json returning self" context at :169 above asserts the error
|
|
216
|
+
# class plus the substring "infinite recursion". That gate passes if the
|
|
217
|
+
# message drifts to e.g. "infinite recursion detected" with no class name
|
|
218
|
+
# and no actionable hint — the user reading a Sentry issue would not learn
|
|
219
|
+
# which model misbehaved or how to fix it. The block below gates the full
|
|
220
|
+
# message shape from gem/lib/ruact/flight/serializer.rb#serialize_as_json:
|
|
221
|
+
# the offending class name + "Ruact::Serializable" + "ruact_props" must all
|
|
222
|
+
# appear so the message remains debuggable in production.
|
|
223
|
+
describe "edge cases (Story 7.7) — as_json returning self message shape", :story_7_7 do
|
|
224
|
+
let(:self_returning_model) do
|
|
225
|
+
Class.new do
|
|
226
|
+
def as_json
|
|
227
|
+
self
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.name
|
|
231
|
+
"SelfModel"
|
|
232
|
+
end
|
|
233
|
+
end.new
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it "names the offending class and points the user at Ruact::Serializable + ruact_props" do
|
|
237
|
+
b = StubBundlerConfig.new
|
|
238
|
+
expect { Renderer.render(self_returning_model, b, strict_serialization: false) }
|
|
239
|
+
.to raise_error(Ruact::SerializationError) do |error|
|
|
240
|
+
expect(error.message).to include("SelfModel")
|
|
241
|
+
expect(error.message).to include("Ruact::Serializable")
|
|
242
|
+
expect(error.message).to include("ruact_props")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
235
247
|
context "with as_json raising an exception" do
|
|
236
248
|
let(:model_raising_error) do
|
|
237
249
|
Class.new do
|
|
@@ -281,18 +293,21 @@ module Ruact
|
|
|
281
293
|
"FakeSerializable"
|
|
282
294
|
end
|
|
283
295
|
|
|
284
|
-
|
|
296
|
+
ruact_props :id, :title
|
|
285
297
|
end.new
|
|
286
298
|
end
|
|
287
299
|
|
|
288
|
-
it "serializes via
|
|
289
|
-
expect(render.call(serializable_obj)).to
|
|
300
|
+
it "serializes via ruact_serialize — only declared props (AC#1)" do
|
|
301
|
+
expect(render.call(serializable_obj)).to include_flight_row(
|
|
302
|
+
class: :model,
|
|
303
|
+
payload: hash_including("id" => 1, "title" => "Hello")
|
|
304
|
+
)
|
|
290
305
|
end
|
|
291
306
|
|
|
292
307
|
it "works with strict_serialization: true — Serializable bypasses strict gate (AC#1)" do
|
|
293
308
|
b = StubBundlerConfig.new
|
|
294
309
|
output = Renderer.render(serializable_obj, b, strict_serialization: true)
|
|
295
|
-
expect(output).to
|
|
310
|
+
expect(output).to include_flight_row(class: :model, payload: hash_including("id" => 1))
|
|
296
311
|
end
|
|
297
312
|
|
|
298
313
|
it "does NOT call on_as_json_warning (AC#3)" do
|
|
@@ -306,6 +321,12 @@ module Ruact
|
|
|
306
321
|
end
|
|
307
322
|
|
|
308
323
|
# --- Fixture Contracts (AC#9) ---
|
|
324
|
+
#
|
|
325
|
+
# Fixture mode is the canonical guard for the Flight wire-format invariants
|
|
326
|
+
# of every primitive type: byte-exact escape rules, scalar canonical bytes,
|
|
327
|
+
# ordering. Story 7.6 deleted the duplicate literal-`eq` blocks that lived
|
|
328
|
+
# at the top of this file; these fixture-mode blocks are now the single
|
|
329
|
+
# source of truth for those invariants.
|
|
309
330
|
|
|
310
331
|
describe "fixture contracts (AC#9)" do
|
|
311
332
|
let(:fixture_render) { ->(model) { Renderer.render(model, ClientManifest.from_hash({})) } }
|
|
@@ -338,6 +359,26 @@ module Ruact
|
|
|
338
359
|
expect(fixture_render.call(3.14)).to match_flight_fixture("number_float")
|
|
339
360
|
end
|
|
340
361
|
|
|
362
|
+
it "bigint (> MAX_SAFE_INTEGER) serializes to fixture as $n<decimal>" do
|
|
363
|
+
expect(fixture_render.call(9_007_199_254_740_992)).to match_flight_fixture("bigint")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it "Float::NAN serializes to fixture as $NaN" do
|
|
367
|
+
expect(fixture_render.call(Float::NAN)).to match_flight_fixture("nan")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it "Float::INFINITY serializes to fixture as $Infinity" do
|
|
371
|
+
expect(fixture_render.call(Float::INFINITY)).to match_flight_fixture("infinity")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "-Float::INFINITY serializes to fixture as $-Infinity" do
|
|
375
|
+
expect(fixture_render.call(-Float::INFINITY)).to match_flight_fixture("negative_infinity")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
it ":undefined serializes to fixture as $undefined" do
|
|
379
|
+
expect(fixture_render.call(:undefined)).to match_flight_fixture("undefined")
|
|
380
|
+
end
|
|
381
|
+
|
|
341
382
|
it "mixed array serializes to fixture" do
|
|
342
383
|
expect(fixture_render.call([1, "a", true, nil])).to match_flight_fixture("array")
|
|
343
384
|
end
|
|
@@ -420,7 +461,7 @@ module Ruact
|
|
|
420
461
|
"Post"
|
|
421
462
|
end
|
|
422
463
|
|
|
423
|
-
|
|
464
|
+
ruact_props :id, :title
|
|
424
465
|
end.new
|
|
425
466
|
end
|
|
426
467
|
|