ruact 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) 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 +164 -0
  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 +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.1 — request-cycle + unit spec for the Story 8.5 upload guard
4
+ # RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
5
+ # `server_functions/endpoint_controller_upload_spec.rb` (removed in the same
6
+ # commit — AC5). Pins, against REAL host-controller routes:
7
+ #
8
+ # - oversized multipart → 413 + structured `upload_limit` payload through
9
+ # the concern's salvaged chain (inventory A7, A14, B7, B9, B11)
10
+ # - guard fires BEFORE CSRF verification on correctly ordered hosts —
11
+ # 413, not 403 (B6 / Pitfall #4)
12
+ # - the three carve-outs: nil limit (B2), content-type allowlist (B3),
13
+ # absent Content-Length (B4); off-by-one equal-passes (B5)
14
+ # - D1: the 413 renders structured for native form submits too (no
15
+ # function-call Accept header) — the documented UploadTooLargeError
16
+ # exception to the re-raise rule
17
+ # - D2: GET/HEAD requests skip the guard entirely (C5)
18
+ # - small uploads reach the action as ActionDispatch::Http::UploadedFile
19
+ # with metadata + mixed non-file fields intact (transplant sanity)
20
+ #
21
+ # Mounts on the shared Story-7.9 Rails app; deliberately independent of the
22
+ # v1 `server_functions/dispatch_request_spec.rb` (demolished in Story 9.9).
23
+
24
+ require "action_controller/railtie"
25
+ require "action_view/railtie"
26
+
27
+ require "spec_helper"
28
+ require "rack/test"
29
+ require "tempfile"
30
+
31
+ require "ruact/server"
32
+
33
+ require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
34
+
35
+ SERVER_UPLOAD_SPEC_PNG_PATH =
36
+ File.expand_path("../support/fixtures/pixel.png", __dir__).freeze
37
+ SERVER_UPLOAD_SPEC_PNG_BYTES = File.binread(SERVER_UPLOAD_SPEC_PNG_PATH).freeze
38
+
39
+ module ServerUploadSpecSupport
40
+ class UploadsServerController < ActionController::Base
41
+ include Ruact::Server
42
+
43
+ def create_upload
44
+ uploaded = params[:cover]
45
+ render json: {
46
+ "title" => params[:title].to_s,
47
+ "uploaded_class" => uploaded.class.name,
48
+ "original_filename" => uploaded.respond_to?(:original_filename) ? uploaded.original_filename : nil,
49
+ "byte_size" => uploaded.respond_to?(:read) ? uploaded.read.bytesize : nil
50
+ }
51
+ end
52
+ end
53
+
54
+ # CSRF-enforcing host for the Pitfall #4 ordering proof (B6) — forgery is
55
+ # flipped on per-example via the class-level `allow_forgery_protection`.
56
+ class ForgeryUploadsServerController < ActionController::Base
57
+ include Ruact::Server
58
+
59
+ protect_from_forgery with: :exception
60
+
61
+ def create_protected_upload
62
+ render json: { "ok" => true }
63
+ end
64
+ end
65
+ end
66
+
67
+ if defined?(ControllerRequestSpecSupport) &&
68
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_upload_routes_appended)
69
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_upload_routes_appended, true)
70
+ ControllerRequestSpecSupport.app_class.routes.append do
71
+ post "/server_upload", to: "server_upload_spec_support/uploads_server#create_upload"
72
+ post "/server_upload/protected", to: "server_upload_spec_support/forgery_uploads_server#create_protected_upload"
73
+ end
74
+ end
75
+
76
+ RSpec.describe "Story 9.1: Ruact::Server concern — salvaged upload guard", :story_9_1 do
77
+ include Rack::Test::Methods
78
+
79
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
80
+ let(:app) { app_class.instance }
81
+
82
+ let(:function_call_accept) { { "HTTP_ACCEPT" => "application/json" } }
83
+
84
+ before do
85
+ Rails.logger = Logger.new(IO::NULL)
86
+ ControllerRequestSpecSupport.boot!
87
+ end
88
+
89
+ def with_oversized_tempfile
90
+ large = Tempfile.new(["big", ".bin"])
91
+ large.binmode
92
+ large.write("x" * 4096) # 4 KB > the 1 KB caps below
93
+ large.rewind
94
+ yield large
95
+ ensure
96
+ large.close
97
+ large.unlink
98
+ end
99
+
100
+ def cap_max_upload_bytes(value)
101
+ # spec_helper's global before-hook resets @config; re-prime AFTER it so
102
+ # the tight cap sticks for the example body (same dance as the v1 spec).
103
+ Ruact.instance_variable_set(:@config, nil)
104
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
105
+ Ruact.configure { |c| c.max_upload_bytes = value }
106
+ end
107
+
108
+ describe "transplant sanity — small multipart upload reaches the action" do
109
+ it "params[:cover] arrives as ActionDispatch::Http::UploadedFile with metadata; mixed fields intact" do
110
+ post "/server_upload",
111
+ {
112
+ "title" => "My Post",
113
+ "cover" => Rack::Test::UploadedFile.new(SERVER_UPLOAD_SPEC_PNG_PATH, "image/png")
114
+ },
115
+ function_call_accept
116
+ expect(last_response.status).to eq(200)
117
+ body = JSON.parse(last_response.body)
118
+ expect(body.fetch("uploaded_class")).to eq("ActionDispatch::Http::UploadedFile")
119
+ expect(body.fetch("original_filename")).to eq("pixel.png")
120
+ expect(body.fetch("byte_size")).to eq(SERVER_UPLOAD_SPEC_PNG_BYTES.bytesize)
121
+ expect(body.fetch("title")).to eq("My Post")
122
+ end
123
+ end
124
+
125
+ describe "oversized multipart → 413 + structured upload_limit payload (A7/A14/B7/B9/B11)" do
126
+ before { cap_max_upload_bytes(1024) }
127
+
128
+ it "function-call request: 413 with discriminator, real action_name, and the dev-only upload_limit block" do
129
+ with_oversized_tempfile do |large|
130
+ post "/server_upload",
131
+ {
132
+ "title" => "Too big",
133
+ "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
134
+ },
135
+ function_call_accept
136
+ expect(last_response.status).to eq(413)
137
+ body = JSON.parse(last_response.body)
138
+ expect(body).to include(
139
+ "_ruact_server_action_error" => true,
140
+ "error_class" => "Ruact::UploadTooLargeError",
141
+ # A14 — the controller's REAL action name, no registry-symbol
142
+ # fallback dance (the v1 path_parameters[:name] fallback is gone).
143
+ "action_name" => "create_upload"
144
+ )
145
+ expect(body.fetch("upload_limit")).to include("limit_bytes" => 1024)
146
+ # B7 — received_bytes is the WIRE Content-Length: file bytes PLUS
147
+ # multipart boundary + field overhead.
148
+ expect(body.fetch("upload_limit").fetch("received_bytes")).to be > 4096
149
+ end
150
+ end
151
+
152
+ it "D1 — a native form submit (no function-call Accept) ALSO gets the structured 413" do
153
+ # UploadTooLargeError is the documented exception to the re-raise rule:
154
+ # the guard only exists on requests that opted into the concern, and a
155
+ # meaningful 413 beats a re-raised 500 for every caller shape.
156
+ with_oversized_tempfile do |large|
157
+ post "/server_upload",
158
+ {
159
+ "title" => "Too big, browser shape",
160
+ "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
161
+ }
162
+ expect(last_response.status).to eq(413)
163
+ body = JSON.parse(last_response.body)
164
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
165
+ expect(body.fetch("error_class")).to eq("Ruact::UploadTooLargeError")
166
+ end
167
+ end
168
+ end
169
+
170
+ describe "guard fires BEFORE CSRF verification (B6 / Pitfall #4)" do
171
+ around do |example|
172
+ previous = ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection
173
+ ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection = true
174
+ example.run
175
+ ensure
176
+ ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection = previous
177
+ end
178
+
179
+ before { cap_max_upload_bytes(1024) }
180
+
181
+ it "oversized request WITHOUT a CSRF token returns 413, not 403" do
182
+ with_oversized_tempfile do |large|
183
+ post "/server_upload/protected",
184
+ {
185
+ "title" => "Too big, no token",
186
+ "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
187
+ },
188
+ function_call_accept
189
+ expect(last_response.status).to eq(413)
190
+ expect(JSON.parse(last_response.body).fetch("error_class")).to eq("Ruact::UploadTooLargeError")
191
+ end
192
+ end
193
+ end
194
+
195
+ describe "carve-out — max_upload_bytes = nil disables the gem-side guard (B2)" do
196
+ before { cap_max_upload_bytes(nil) }
197
+
198
+ it "a body of any size flows through to the action" do
199
+ with_oversized_tempfile do |large|
200
+ post "/server_upload",
201
+ {
202
+ "title" => "no cap",
203
+ "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
204
+ },
205
+ function_call_accept
206
+ expect(last_response.status).to eq(200)
207
+ expect(JSON.parse(last_response.body).fetch("uploaded_class")).to eq("ActionDispatch::Http::UploadedFile")
208
+ end
209
+ end
210
+ end
211
+
212
+ describe "carve-out — application/json bypasses the guard (B3, request-cycle)" do
213
+ before { cap_max_upload_bytes(1024) }
214
+
215
+ it "a 4 KB JSON body passes the guard and reaches the action" do
216
+ payload = { "title" => "big json", "blob" => "x" * 4096 }
217
+ post "/server_upload", payload.to_json,
218
+ { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" }
219
+ expect(last_response.status).to eq(200)
220
+ # No file in a JSON body — the action reports the params leaf class.
221
+ expect(JSON.parse(last_response.body).fetch("uploaded_class")).to eq("NilClass")
222
+ end
223
+ end
224
+
225
+ # Unit-level coverage of the short-circuit branches — directly against a
226
+ # host controller instance, mirroring the v1 unit block but with the
227
+ # concern's D2 verb gate in play (request.get? / request.head?).
228
+ describe "Unit — __ruact_enforce_upload_limit! short-circuits (B2–B5, C5/D2)" do
229
+ let(:controller) { ServerUploadSpecSupport::UploadsServerController.new }
230
+
231
+ def with_max_upload_bytes(value)
232
+ cap_max_upload_bytes(value)
233
+ yield
234
+ ensure
235
+ Ruact.instance_variable_set(:@config, nil)
236
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
237
+ end
238
+
239
+ def stub_request(content_type:, content_length:, http_method: "POST")
240
+ request = instance_double(
241
+ ActionDispatch::Request,
242
+ content_length: content_length,
243
+ get?: http_method == "GET",
244
+ head?: http_method == "HEAD"
245
+ )
246
+ allow(request).to receive(:content_mime_type)
247
+ .and_return(content_type ? Mime::Type.lookup(content_type) : nil)
248
+ allow(controller).to receive(:request).and_return(request)
249
+ end
250
+
251
+ it "B4 — nil Content-Length (chunked transfer) bypasses the guard" do
252
+ with_max_upload_bytes(1024) do
253
+ stub_request(content_type: "multipart/form-data", content_length: nil)
254
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
255
+ end
256
+ end
257
+
258
+ it "B2 — max_upload_bytes = nil bypasses the guard even with oversized Content-Length" do
259
+ with_max_upload_bytes(nil) do
260
+ stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024)
261
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
262
+ end
263
+ end
264
+
265
+ it "B3 — application/json content type bypasses the guard" do
266
+ with_max_upload_bytes(1024) do
267
+ stub_request(content_type: "application/json", content_length: 10 * 1024 * 1024)
268
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
269
+ end
270
+ end
271
+
272
+ it "B3 — missing content type (nil) bypasses the guard" do
273
+ with_max_upload_bytes(1024) do
274
+ stub_request(content_type: nil, content_length: 10_000)
275
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
276
+ end
277
+ end
278
+
279
+ it "B3/B8 — application/x-www-form-urlencoded is subject to the guard" do
280
+ with_max_upload_bytes(1024) do
281
+ stub_request(content_type: "application/x-www-form-urlencoded", content_length: 2048)
282
+ expect { controller.send(:__ruact_enforce_upload_limit!) }
283
+ .to raise_error(Ruact::UploadTooLargeError) do |error|
284
+ expect(error.received_bytes).to eq(2048)
285
+ expect(error.limit_bytes).to eq(1024)
286
+ end
287
+ end
288
+ end
289
+
290
+ it "B5 — Content-Length exactly equal to the limit passes the guard (off-by-one)" do
291
+ with_max_upload_bytes(1024) do
292
+ stub_request(content_type: "multipart/form-data", content_length: 1024)
293
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
294
+ end
295
+ end
296
+
297
+ it "C5/D2 — a GET request skips the guard even with an oversized multipart body" do
298
+ with_max_upload_bytes(1024) do
299
+ stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024, http_method: "GET")
300
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
301
+ end
302
+ end
303
+
304
+ it "C5/D2 — a HEAD request skips the guard" do
305
+ with_max_upload_bytes(1024) do
306
+ stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024, http_method: "HEAD")
307
+ expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
308
+ end
309
+ end
310
+ end
311
+ end
@@ -5,42 +5,48 @@ require "active_support/core_ext/string/output_safety"
5
5
 
6
6
  module Ruact
7
7
  RSpec.describe ViewHelper do
8
+ let(:render_context) { RenderContext.new }
8
9
  let(:helper_obj) do
9
10
  obj = Object.new
10
11
  obj.extend(described_class)
12
+ obj.instance_variable_set(:@ruact_render_context, render_context)
11
13
  obj
12
14
  end
13
15
 
14
- before { ComponentRegistry.start }
15
- after { ComponentRegistry.reset }
16
-
17
- describe "#__rsc_component__" do
18
- it "registers the component in ComponentRegistry and returns an HTML comment" do
19
- result = helper_obj.__rsc_component__("NavBar", { "currentUser" => 1 })
20
- expect(result).to match(/<!-- __RSC_\d+__ -->/)
21
- expect(ComponentRegistry.components.length).to eq(1)
22
- expect(ComponentRegistry.components.first[:name]).to eq("NavBar")
23
- expect(ComponentRegistry.components.first[:props]).to eq({ "currentUser" => 1 })
16
+ describe "#__ruact_component__" do
17
+ it "registers the component in the render context and returns an HTML comment" do
18
+ result = helper_obj.__ruact_component__("NavBar", { "currentUser" => 1 })
19
+ expect(result).to match(/<!-- __RUACT_\d+__ -->/)
20
+ expect(render_context.components.length).to eq(1)
21
+ expect(render_context.components.first[:name]).to eq("NavBar")
22
+ expect(render_context.components.first[:props]).to eq({ "currentUser" => 1 })
24
23
  end
25
24
 
26
25
  it "returns an html_safe string so ActionView does not escape the comment" do
27
- result = helper_obj.__rsc_component__("Button", {})
26
+ result = helper_obj.__ruact_component__("Button", {})
28
27
  expect(result).to be_html_safe
29
28
  end
30
29
 
31
30
  it "uses incrementing token numbers for successive registrations" do
32
- token0 = helper_obj.__rsc_component__("Foo", {})
33
- token1 = helper_obj.__rsc_component__("Bar", {})
34
- expect(token0).to include("__RSC_0__")
35
- expect(token1).to include("__RSC_1__")
31
+ token0 = helper_obj.__ruact_component__("Foo", {})
32
+ token1 = helper_obj.__ruact_component__("Bar", {})
33
+ expect(token0).to include("__RUACT_0__")
34
+ expect(token1).to include("__RUACT_1__")
36
35
  end
37
36
 
38
37
  it "passes props through to the registry entry" do
39
- helper_obj.__rsc_component__("LikeButton", { "postId" => 42, "label" => "Like" })
40
- entry = ComponentRegistry.components.first
38
+ helper_obj.__ruact_component__("LikeButton", { "postId" => 42, "label" => "Like" })
39
+ entry = render_context.components.first
41
40
  expect(entry[:props]["postId"]).to eq(42)
42
41
  expect(entry[:props]["label"]).to eq("Like")
43
42
  end
43
+
44
+ it "raises a clear error when called outside a ruact_render flow" do
45
+ bare = Object.new
46
+ bare.extend(described_class)
47
+ expect { bare.__ruact_component__("NavBar", {}) }
48
+ .to raise_error(Ruact::Error, /__ruact_component__ called outside a ruact_render flow/)
49
+ end
44
50
  end
45
51
  end
46
52
  end
data/spec/spec_helper.rb CHANGED
@@ -1,9 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "simplecov"
4
+ require "simplecov-lcov"
5
+
6
+ SimpleCov::Formatter::LcovFormatter.config do |c|
7
+ c.report_with_single_file = true
8
+ c.single_report_path = "coverage/lcov.info"
9
+ end
10
+
11
+ SimpleCov.start do
12
+ enable_coverage :branch
13
+ primary_coverage :line
14
+ formatter SimpleCov::Formatter::MultiFormatter.new(
15
+ [
16
+ SimpleCov::Formatter::HTMLFormatter,
17
+ SimpleCov::Formatter::LcovFormatter
18
+ ]
19
+ )
20
+ add_filter %r{^/spec/}
21
+ add_filter %r{^/bin/}
22
+ add_filter %r{lib/generators/.+/templates/}
23
+ add_filter "lib/ruact/version.rb"
24
+ end
25
+
3
26
  require "logger"
4
27
  require "ruact"
5
28
 
6
- Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f }
29
+ Dir[File.join(__dir__, "support", "**", "*.rb")].each do |f|
30
+ next if f.end_with?("_spec.rb") # spec files are loaded by the RSpec runner; avoid double registration
31
+
32
+ require f
33
+ end
7
34
 
8
35
  RSpec.configure do |config|
9
36
  config.order = :random
@@ -13,4 +40,28 @@ RSpec.configure do |config|
13
40
  config.mock_with :rspec do |mocks|
14
41
  mocks.verify_partial_doubles = true
15
42
  end
43
+
44
+ # Story 7.3: Ruact.config is a frozen singleton with a boot-state flag for the
45
+ # re-configuration warning. Reset both before every example so the boot flag
46
+ # cannot leak between specs — otherwise the AC3 warning may fire into a
47
+ # Rails.logger that has become a per-example RSpec double from an earlier
48
+ # spec, producing "originally created in one example but has leaked" failures
49
+ # under random order.
50
+ config.before do
51
+ Ruact.instance_variable_set(:@config, nil)
52
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
53
+ # Story 8.0a: both server-function registries are lazy-initialized module
54
+ # singletons. Wipe them between examples so register/clear specs in one file
55
+ # cannot bleed entries into another under random order.
56
+ Ruact.instance_variable_set(:@action_registry, nil)
57
+ Ruact.instance_variable_set(:@query_registry, nil)
58
+ # Story 9.3: specs that set `Rails.logger = instance_double(Logger)` (e.g.
59
+ # railtie_spec, configuration_spec) leave a per-example double on the global
60
+ # after they finish; rspec then refuses to call it from the NEXT example.
61
+ # That was harmless until the codegen/rake paths began reading `Rails.logger`
62
+ # (the `[ruact] codegen: exposing …` line). Reset it to a real, silent logger
63
+ # before every example so no leaked double survives — examples that need a
64
+ # specific logger set their own in their own `before` (which runs after this).
65
+ Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
66
+ end
16
67
  end
Binary file
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "json"
5
+
6
+ module Ruact
7
+ # Test-support utilities. Code under `Ruact::Spec` is consumed only by the
8
+ # gem's own RSpec suite; it is not part of the public API and may change
9
+ # shape across stories without a deprecation cycle.
10
+ module Spec
11
+ # Raised when {FlightWireParser.parse} encounters input it cannot decode.
12
+ # The message names the byte offset of the unparseable row so the spec
13
+ # author can locate the problem in a printed wire string.
14
+ class FlightWireParseError < StandardError; end
15
+
16
+ # Parses a Flight wire byte string into an ordered array of row records.
17
+ #
18
+ # Used by the structural Flight RSpec matchers
19
+ # (`match_flight_structure` / `include_flight_row`) to assert on parsed
20
+ # semantics rather than literal bytes. Pure function — no I/O, no global
21
+ # state, no `Thread.current`.
22
+ #
23
+ # @example
24
+ # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
25
+ # Ruact::Spec::FlightWireParser.parse(wire)
26
+ # # => [
27
+ # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
28
+ # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
29
+ # # ]
30
+ class FlightWireParser
31
+ # Parse a complete Flight wire byte string.
32
+ #
33
+ # @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
34
+ # @return [Array<Hash>] one hash per row, in wire order. See class docs
35
+ # for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
36
+ # @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
37
+ def self.parse(wire)
38
+ rows = []
39
+ scanner = StringScanner.new(wire)
40
+
41
+ until scanner.eos?
42
+ start_offset = scanner.pos
43
+ rows << parse_row(scanner, start_offset)
44
+ end
45
+
46
+ rows
47
+ end
48
+
49
+ def self.parse_row(scanner, start_offset)
50
+ # Hint rows have no ID: ":H<code><json>\n"
51
+ if scanner.peek(2) == ":H"
52
+ scanner.pos += 2
53
+ code = scanner.getch
54
+ raise_parse_error(start_offset, "missing hint code char") if code.nil?
55
+
56
+ json = read_to_newline(scanner, start_offset)
57
+ return {
58
+ id: nil,
59
+ class: :hint,
60
+ payload: [code, parse_json(json, start_offset)],
61
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
62
+ }
63
+ end
64
+
65
+ hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
66
+ scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
67
+ id = hex.to_i(16)
68
+
69
+ case scanner.peek(1)
70
+ when "I" then parse_tagged(:import, scanner, id, start_offset)
71
+ when "T" then parse_text_row(scanner, id, start_offset)
72
+ when "E" then parse_tagged(:error, scanner, id, start_offset)
73
+ else parse_model_row(scanner, id, start_offset)
74
+ end
75
+ end
76
+
77
+ def self.parse_tagged(klass, scanner, id, start_offset)
78
+ scanner.getch # consume the tag byte (I or E)
79
+ json = read_to_newline(scanner, start_offset)
80
+ {
81
+ id: id,
82
+ class: klass,
83
+ payload: parse_json(json, start_offset),
84
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
85
+ }
86
+ end
87
+
88
+ def self.parse_model_row(scanner, id, start_offset)
89
+ json = read_to_newline(scanner, start_offset)
90
+ {
91
+ id: id,
92
+ class: :model,
93
+ payload: parse_json(json, start_offset),
94
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
95
+ }
96
+ end
97
+
98
+ def self.parse_text_row(scanner, id, start_offset)
99
+ scanner.getch # consume "T"
100
+ len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
101
+ scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
102
+ len = len_hex.to_i(16)
103
+
104
+ text = scanner.peek(len)
105
+ raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
106
+
107
+ scanner.pos += len
108
+ {
109
+ id: id,
110
+ class: :text,
111
+ payload: text,
112
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
113
+ }
114
+ end
115
+
116
+ def self.read_to_newline(scanner, start_offset)
117
+ line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
118
+ line.chomp
119
+ end
120
+
121
+ def self.parse_json(str, offset)
122
+ JSON.parse(str)
123
+ rescue JSON::ParserError => e
124
+ raise_parse_error(offset, "invalid JSON: #{e.message}")
125
+ end
126
+
127
+ def self.raise_parse_error(offset, reason)
128
+ raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
129
+ end
130
+
131
+ private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
132
+ :read_to_newline, :parse_json, :raise_parse_error
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "flight_wire_parser"
5
+
6
+ module Ruact
7
+ module Spec
8
+ RSpec.describe FlightWireParser do
9
+ describe ".parse" do
10
+ it "parses a single model row" do
11
+ rows = described_class.parse(%(0:{"className":"box"}\n))
12
+
13
+ expect(rows).to eq([
14
+ {
15
+ id: 0,
16
+ class: :model,
17
+ payload: { "className" => "box" },
18
+ raw: %(0:{"className":"box"}\n)
19
+ }
20
+ ])
21
+ end
22
+
23
+ it "parses a single import row and decodes the hex id" do
24
+ rows = described_class.parse(%(a:I["/L.jsx","L",["/L.jsx"]]\n))
25
+
26
+ expect(rows.length).to eq(1)
27
+ expect(rows.first).to include(
28
+ id: 10,
29
+ class: :import,
30
+ payload: ["/L.jsx", "L", ["/L.jsx"]]
31
+ )
32
+ end
33
+
34
+ it "parses a single error row" do
35
+ rows = described_class.parse(%(2:E{"message":"boom"}\n))
36
+
37
+ expect(rows.first).to include(
38
+ id: 2,
39
+ class: :error,
40
+ payload: { "message" => "boom" }
41
+ )
42
+ end
43
+
44
+ it "parses a hint row with no id" do
45
+ rows = described_class.parse(%(:HL"/preload.js"\n))
46
+
47
+ expect(rows.first).to include(
48
+ id: nil,
49
+ class: :hint,
50
+ payload: ["L", "/preload.js"]
51
+ )
52
+ end
53
+
54
+ it "parses a T row of exactly LARGE_TEXT_THRESHOLD bytes followed by a model row that references it" do
55
+ large_text = "a" * 1024
56
+ wire = "1:T#{1024.to_s(16)},#{large_text}0:\"$T1\"\n"
57
+
58
+ rows = described_class.parse(wire)
59
+
60
+ expect(rows.length).to eq(2)
61
+ expect(rows[0]).to include(id: 1, class: :text, payload: large_text)
62
+ expect(rows[1]).to include(id: 0, class: :model, payload: "$T1")
63
+ end
64
+
65
+ it "parses a mixed-row sequence preserving wire order" do
66
+ wire = %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n)
67
+
68
+ rows = described_class.parse(wire)
69
+
70
+ expect(rows.map { |r| [r[:id], r[:class]] }).to eq([[1, :import], [0, :model]])
71
+ end
72
+
73
+ it "parses empty input as an empty array" do
74
+ expect(described_class.parse("")).to eq([])
75
+ end
76
+
77
+ it "raises FlightWireParseError naming the byte offset on malformed JSON" do
78
+ wire = %(0:{not-json}\n)
79
+
80
+ expect { described_class.parse(wire) }
81
+ .to raise_error(FlightWireParseError, /cannot parse row at offset \d+/)
82
+ end
83
+
84
+ it "raises FlightWireParseError when a T row is truncated" do
85
+ wire = "0:T#{10.to_s(16)},abc" # claims 16 bytes but provides 3
86
+
87
+ expect { described_class.parse(wire) }
88
+ .to raise_error(FlightWireParseError, /cannot parse row at offset 0: T row truncated/)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end