ruact 0.0.2 → 0.0.4

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 (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.1 — unit surface of the `Ruact::Server` concern (route-driven
4
+ # redesign, Phase A). Pins:
5
+ #
6
+ # - AC1 "and nothing else": including the concern installs EXACTLY two
7
+ # `rescue_from` handlers + one prepended before_action, adds no public
8
+ # instance methods, and registers nothing in the v1 registries (codegen
9
+ # exposure is Story 9.3's job — the concern is a pure marker + salvage
10
+ # host until then).
11
+ # - AC2 / D3: the `__ruact_function_call?` predicate matrix — the single
12
+ # named discrimination point Story 9.2 reuses. Keyed on the raw `Accept`
13
+ # header containing `application/json` (what the 8.1 runtime sends on
14
+ # every `_makeRef` fetch); deliberately NOT `request.format`, which is
15
+ # influenced by path extensions and `params[:format]`.
16
+ #
17
+ # Request-cycle behavior (error chain, upload guard) is pinned by
18
+ # `server_rescue_request_spec.rb` / `server_upload_request_spec.rb`.
19
+
20
+ require "action_controller/railtie"
21
+
22
+ require "spec_helper"
23
+ require "open3"
24
+
25
+ require "ruact/server"
26
+
27
+ module ServerConcernUnitSupport
28
+ # Baseline for "nothing else" comparisons.
29
+ class PlainController < ActionController::Base
30
+ end
31
+
32
+ class ConcernController < ActionController::Base
33
+ include Ruact::Server
34
+ end
35
+ end
36
+
37
+ RSpec.describe Ruact::Server, :story_9_1 do
38
+ describe "Story 9.1 — installation surface (AC1: the salvaged chains and nothing else)" do
39
+ it "installs exactly the two salvaged rescue_from handlers, in the v1 registration order" do
40
+ # Pitfall #1 parity: StandardError first, explicit
41
+ # InvalidAuthenticityToken second — the later registration wins the
42
+ # most-recently-registered walk, preempting Rails' default
43
+ # handle_unverified_request for CSRF failures.
44
+ expect(ServerConcernUnitSupport::PlainController.rescue_handlers).to eq([])
45
+ expect(ServerConcernUnitSupport::ConcernController.rescue_handlers.map(&:first)).to eq(
46
+ ["StandardError", "ActionController::InvalidAuthenticityToken"]
47
+ )
48
+ end
49
+
50
+ it "prepends the upload guard as the FIRST before_action (Pitfall #4 ordering)" do
51
+ before_filters = ServerConcernUnitSupport::ConcernController
52
+ ._process_action_callbacks
53
+ .select { |callback| callback.kind == :before }
54
+ .map(&:filter)
55
+ expect(before_filters.first).to eq(:__ruact_enforce_upload_limit!)
56
+ end
57
+
58
+ it "adds NO public instance methods to the host (predicate + handlers are private)" do
59
+ added = ServerConcernUnitSupport::ConcernController.public_instance_methods -
60
+ ServerConcernUnitSupport::PlainController.public_instance_methods
61
+ expect(added).to eq([])
62
+ end
63
+
64
+ it "registers nothing in the v1 registries (codegen exposure is Story 9.3, not 9.1)" do
65
+ expect(Ruact.action_registry.entries).to be_empty
66
+ expect(Ruact.query_registry.entries).to be_empty
67
+ end
68
+
69
+ it "keeps INHERITED host rescue_from handlers more recent than its own (review patch)",
70
+ :aggregate_failures do
71
+ # Rails resolves handlers by walking `rescue_handlers` from the most
72
+ # recently registered entry backwards. The concern therefore places its
73
+ # entries at the FRONT of the array, so every host handler — inherited
74
+ # from a parent class or declared after the include — stays more recent
75
+ # and keeps precedence.
76
+ parent = Class.new(ActionController::Base) do
77
+ rescue_from ArgumentError, with: :host_handler
78
+ end
79
+ child = Class.new(parent) { include Ruact::Server }
80
+ expect(child.rescue_handlers.map(&:first)).to eq(
81
+ ["StandardError", "ActionController::InvalidAuthenticityToken", "ArgumentError"]
82
+ )
83
+ # The parent's own registry is untouched (class_attribute write lands
84
+ # on the child only).
85
+ expect(parent.rescue_handlers.map(&:first)).to eq(["ArgumentError"])
86
+ end
87
+ end
88
+
89
+ describe "Story 9.1 — standalone load path (review patch)" do
90
+ it "a direct require \"ruact/server\" resolves Ruact.config and the error constants" do
91
+ lib = File.expand_path("../../lib", __dir__)
92
+ script = <<~RUBY
93
+ require "ruact/server"
94
+ exit 1 unless defined?(Ruact::Server)
95
+ exit 2 unless defined?(Ruact::UploadTooLargeError)
96
+ exit 3 unless Ruact.config.respond_to?(:max_upload_bytes)
97
+ exit 4 unless defined?(Ruact::ServerFunctions::ErrorRendering)
98
+ RUBY
99
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", lib, "-e", script)
100
+ expect(status).to be_success, "standalone require failed (exit #{status.exitstatus}): #{stderr}"
101
+ end
102
+ end
103
+
104
+ # Simplified Story 9.1 contract — the runtime sends the exact
105
+ # `Accept: application/json` shape, and that exact header is the only
106
+ # JSON-Accept signal this concern recognizes.
107
+ describe "Story 9.1 — __ruact_json_accept? exact-header matrix" do
108
+ let(:controller) { ServerConcernUnitSupport::ConcernController.new }
109
+
110
+ def stub_accept_header(value)
111
+ request = instance_double(ActionDispatch::Request)
112
+ allow(request).to receive(:headers).and_return({ "Accept" => value })
113
+ allow(controller).to receive(:request).and_return(request)
114
+ end
115
+
116
+ it "is true for the runtime's exact shape (Accept: application/json)" do
117
+ stub_accept_header("application/json")
118
+ expect(controller.send(:__ruact_json_accept?)).to be(true)
119
+ end
120
+
121
+ it "is false for a composite Accept header" do
122
+ stub_accept_header("application/json, text/plain, */*")
123
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
124
+ end
125
+
126
+ it "is false for browser navigation Accept headers" do
127
+ stub_accept_header("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
128
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
129
+ end
130
+
131
+ it "is false for Flight requests (Accept: text/x-component)" do
132
+ stub_accept_header("text/x-component")
133
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
134
+ end
135
+
136
+ it "is false when the Accept header is absent (strict boolean, not nil)" do
137
+ stub_accept_header(nil)
138
+ expect(controller.send(:__ruact_json_accept?)).to be(false)
139
+ end
140
+ end
141
+
142
+ # Review patch (2026-06-08) — `__ruact_function_call?` is now the SEMANTIC
143
+ # predicate Story 9.2 reuses: a JSON-Accept request that is ALSO non-GET/HEAD
144
+ # (function calls are non-GET by the verb rule, epic contract decision #1).
145
+ # The verb gate moved off the error-renderer and into the predicate itself,
146
+ # so 9.2 inherits the correct contract from one place.
147
+ describe "Story 9.1 — __ruact_function_call? semantic predicate (verb-gated, review patch)" do
148
+ let(:controller) { ServerConcernUnitSupport::ConcernController.new }
149
+
150
+ def stub_request(accept:, verb: "POST")
151
+ request = instance_double(
152
+ ActionDispatch::Request,
153
+ headers: { "Accept" => accept },
154
+ get?: verb == "GET",
155
+ head?: verb == "HEAD"
156
+ )
157
+ allow(controller).to receive(:request).and_return(request)
158
+ end
159
+
160
+ it "is true for a non-GET JSON request (THE Story 9.2 discrimination point)" do
161
+ stub_request(accept: "application/json", verb: "POST")
162
+ expect(controller.send(:__ruact_function_call?)).to be(true)
163
+ end
164
+
165
+ it "is false for a GET carrying Accept: application/json (verb rule — not a function call)" do
166
+ stub_request(accept: "application/json", verb: "GET")
167
+ expect(controller.send(:__ruact_function_call?)).to be(false)
168
+ end
169
+
170
+ it "is false for a HEAD carrying Accept: application/json" do
171
+ stub_request(accept: "application/json", verb: "HEAD")
172
+ expect(controller.send(:__ruact_function_call?)).to be(false)
173
+ end
174
+
175
+ it "is false for a non-GET request without a JSON Accept (Bucket-1 form submit)" do
176
+ stub_request(accept: "text/html", verb: "POST")
177
+ expect(controller.send(:__ruact_function_call?)).to be(false)
178
+ end
179
+ end
180
+ end
@@ -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