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.
- 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 +164 -0
- 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 +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 9.1 — request-cycle spec for the Story 8.4 structured-error chain
|
|
4
|
+
# RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
|
|
5
|
+
# `server_functions/endpoint_controller_rescue_spec.rb` (removed in the same
|
|
6
|
+
# commit — AC5: no orphan salvage, no double coverage). Pins, against REAL
|
|
7
|
+
# host-controller routes (no `/__ruact/fn/` anywhere):
|
|
8
|
+
#
|
|
9
|
+
# - structured payload on function-call requests: discriminator, baseline
|
|
10
|
+
# fields, dev extras, validation_errors, suggestion (inventory A1, A2,
|
|
11
|
+
# A5, A6, A9)
|
|
12
|
+
# - host `rescue_from` precedence (A12)
|
|
13
|
+
# - production-mode reduction to the four baseline keys (A8)
|
|
14
|
+
# - strict-boolean `dev_error_payload_enabled` handling — review F3 (A10)
|
|
15
|
+
# - server-side logging always fires (A11)
|
|
16
|
+
# - CSRF failure → 403 + structured body with the CSRF suggestion (A13)
|
|
17
|
+
# - AC1 byte-for-byte: GET page actions render untouched (C2); a raise on
|
|
18
|
+
# a NON-function-call request propagates to Rails' default handling —
|
|
19
|
+
# no structured JSON swallow (C3 / D1)
|
|
20
|
+
#
|
|
21
|
+
# Mounts on the shared Story-7.9 Rails app (`controller_request_spec.rb`).
|
|
22
|
+
# Deliberately does NOT depend on `server_functions/dispatch_request_spec.rb`
|
|
23
|
+
# — that file pins the v1 endpoint and is demolished in Story 9.9; this file
|
|
24
|
+
# must survive it.
|
|
25
|
+
|
|
26
|
+
require "action_controller/railtie"
|
|
27
|
+
require "action_view/railtie"
|
|
28
|
+
|
|
29
|
+
require "spec_helper"
|
|
30
|
+
require "rack/test"
|
|
31
|
+
|
|
32
|
+
require "ruact/server"
|
|
33
|
+
|
|
34
|
+
require "active_model"
|
|
35
|
+
require "active_record"
|
|
36
|
+
require "i18n"
|
|
37
|
+
|
|
38
|
+
# Locale loader (same pattern as the v1 request specs) so
|
|
39
|
+
# RecordInvalid#message resolves cleanly.
|
|
40
|
+
{
|
|
41
|
+
"activemodel" => "active_model",
|
|
42
|
+
"activerecord" => "active_record"
|
|
43
|
+
}.each do |gem_name, dir|
|
|
44
|
+
spec = Gem.loaded_specs[gem_name]
|
|
45
|
+
next unless spec
|
|
46
|
+
|
|
47
|
+
locale_file = File.join(spec.gem_dir, "lib", dir, "locale", "en.yml")
|
|
48
|
+
I18n.load_path << locale_file if File.exist?(locale_file)
|
|
49
|
+
end
|
|
50
|
+
I18n.backend.load_translations
|
|
51
|
+
|
|
52
|
+
# Constant-gated load of the shared Rails app — RSpec loads spec files via
|
|
53
|
+
# `Kernel.load` (no $LOADED_FEATURES dedupe), so an unconditional
|
|
54
|
+
# require_relative would re-run controller_request_spec.rb's top-level
|
|
55
|
+
# routes.append and crash with "Invalid route name, already in use".
|
|
56
|
+
require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
|
|
57
|
+
|
|
58
|
+
module ServerRescueSpecSupport
|
|
59
|
+
# The exact wire shape the 8.1 runtime sends on every `_makeRef` fetch:
|
|
60
|
+
# JSON body + `Accept: application/json` — the Bucket-2 / function-call
|
|
61
|
+
# request shape the concern's predicate keys on.
|
|
62
|
+
FUNCTION_CALL_HEADERS = {
|
|
63
|
+
"CONTENT_TYPE" => "application/json",
|
|
64
|
+
"HTTP_ACCEPT" => "application/json"
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
# Lightweight ActiveModel-shaped class so we can construct a REAL
|
|
68
|
+
# `ActiveRecord::RecordInvalid` instance.
|
|
69
|
+
class RescuePost
|
|
70
|
+
include ActiveModel::Model
|
|
71
|
+
|
|
72
|
+
attr_accessor :title
|
|
73
|
+
|
|
74
|
+
validates :title, presence: true
|
|
75
|
+
|
|
76
|
+
def self.i18n_scope
|
|
77
|
+
:activerecord
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Host with NO rescue_from of its own — exceptions reach the concern's
|
|
82
|
+
# salvaged `rescue_from StandardError`. Includes ONLY Ruact::Server: the
|
|
83
|
+
# concern must work standalone (Bucket-1 rendering comes from the host's
|
|
84
|
+
# separate Ruact::Controller include in real apps; coupling them is 9.2's
|
|
85
|
+
# design space).
|
|
86
|
+
class BareServerController < ActionController::Base
|
|
87
|
+
include Ruact::Server
|
|
88
|
+
|
|
89
|
+
def record_invalid
|
|
90
|
+
record = RescuePost.new
|
|
91
|
+
record.valid? # populates record.errors
|
|
92
|
+
raise ActiveRecord::RecordInvalid, record
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def argument_error
|
|
96
|
+
raise ArgumentError, "bad arg"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def runtime_error
|
|
100
|
+
raise "boom"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def create_ok
|
|
104
|
+
render json: { "ok" => true }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def page
|
|
108
|
+
render plain: "plain page body"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def erroring_page
|
|
112
|
+
raise "boom on GET"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Review patch (2026-06-08) — a host action that manually raises
|
|
116
|
+
# UploadTooLargeError on a GET. The structured-413 exception to the
|
|
117
|
+
# re-raise rule must NOT fire here: GET/HEAD always keep stock Rails
|
|
118
|
+
# behavior (the guard itself never produces this on a GET — D2 — so a
|
|
119
|
+
# structured 413 here could only come from a manual raise).
|
|
120
|
+
def upload_error_on_get
|
|
121
|
+
raise Ruact::UploadTooLargeError.new(received_bytes: 10, limit_bytes: 5)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Review patch (2026-06-08, round 3) — a host action that raises
|
|
125
|
+
# Ruact::ConfigurationError on a function-call request. Configuration
|
|
126
|
+
# invariants must stay LOUD setup failures: the structured-error renderer
|
|
127
|
+
# must re-raise instead of folding the failure into an ordinary
|
|
128
|
+
# `_ruact_server_action_error` 500.
|
|
129
|
+
def config_error
|
|
130
|
+
raise Ruact::ConfigurationError, "config invariant violated"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Parent WITHOUT the concern that catches RecordInvalid; the child includes
|
|
135
|
+
# `Ruact::Server`. Proves the concern's chain does not preempt handlers the
|
|
136
|
+
# host INHERITED either (review patch — `rescue_handlers` walks
|
|
137
|
+
# most-recently-registered first, and a naive include lands the concern's
|
|
138
|
+
# entries after the parent's).
|
|
139
|
+
class ParentRescuingController < ActionController::Base
|
|
140
|
+
rescue_from ActiveRecord::RecordInvalid do |error|
|
|
141
|
+
render(
|
|
142
|
+
json: { caught_by_parent: true, error_class: error.class.name },
|
|
143
|
+
status: :unprocessable_entity
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class InheritedCaughtServerController < ParentRescuingController
|
|
149
|
+
include Ruact::Server
|
|
150
|
+
|
|
151
|
+
def record_invalid
|
|
152
|
+
record = RescuePost.new
|
|
153
|
+
record.valid?
|
|
154
|
+
raise ActiveRecord::RecordInvalid, record
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Host that catches RecordInvalid itself — proves the concern's chain does
|
|
159
|
+
# NOT preempt a host's own rescue_from (inventory A12).
|
|
160
|
+
class CaughtServerController < ActionController::Base
|
|
161
|
+
include Ruact::Server
|
|
162
|
+
|
|
163
|
+
rescue_from ActiveRecord::RecordInvalid do |error|
|
|
164
|
+
render(
|
|
165
|
+
json: { caught_by_host: true, error_class: error.class.name },
|
|
166
|
+
status: :unprocessable_entity
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def record_invalid
|
|
171
|
+
record = RescuePost.new
|
|
172
|
+
record.valid?
|
|
173
|
+
raise ActiveRecord::RecordInvalid, record
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Host with real CSRF enforcement — the concern's explicit
|
|
178
|
+
# InvalidAuthenticityToken registration must render the structured 403
|
|
179
|
+
# for function-call requests (inventory A13). Forgery is flipped on
|
|
180
|
+
# per-example via `allow_forgery_protection` (class-level), mirroring the
|
|
181
|
+
# v1 spec pattern — but on the HOST controller now, not EndpointController.
|
|
182
|
+
class ForgeryServerController < ActionController::Base
|
|
183
|
+
include Ruact::Server
|
|
184
|
+
|
|
185
|
+
protect_from_forgery with: :exception
|
|
186
|
+
|
|
187
|
+
def create_protected
|
|
188
|
+
render json: { "ok" => true }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if defined?(ControllerRequestSpecSupport) &&
|
|
194
|
+
!ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_rescue_routes_appended)
|
|
195
|
+
ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_rescue_routes_appended, true)
|
|
196
|
+
ControllerRequestSpecSupport.app_class.routes.append do
|
|
197
|
+
get "/server_rescue/page", to: "server_rescue_spec_support/bare_server#page"
|
|
198
|
+
get "/server_rescue/erroring_page", to: "server_rescue_spec_support/bare_server#erroring_page"
|
|
199
|
+
get "/server_rescue/upload_error_on_get", to: "server_rescue_spec_support/bare_server#upload_error_on_get"
|
|
200
|
+
post "/server_rescue/record_invalid", to: "server_rescue_spec_support/bare_server#record_invalid"
|
|
201
|
+
post "/server_rescue/argument_error", to: "server_rescue_spec_support/bare_server#argument_error"
|
|
202
|
+
post "/server_rescue/runtime_error", to: "server_rescue_spec_support/bare_server#runtime_error"
|
|
203
|
+
post "/server_rescue/create_ok", to: "server_rescue_spec_support/bare_server#create_ok"
|
|
204
|
+
post "/server_rescue/config_error", to: "server_rescue_spec_support/bare_server#config_error"
|
|
205
|
+
post "/server_rescue/caught_record_invalid", to: "server_rescue_spec_support/caught_server#record_invalid"
|
|
206
|
+
post "/server_rescue/protected", to: "server_rescue_spec_support/forgery_server#create_protected"
|
|
207
|
+
post "/server_rescue/inherited_caught_record_invalid",
|
|
208
|
+
to: "server_rescue_spec_support/inherited_caught_server#record_invalid"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
RSpec.describe "Story 9.1: Ruact::Server concern — salvaged rescue_from chain", :story_9_1 do
|
|
213
|
+
include Rack::Test::Methods
|
|
214
|
+
|
|
215
|
+
let(:app_class) { ControllerRequestSpecSupport.app_class }
|
|
216
|
+
let(:app) { app_class.instance }
|
|
217
|
+
|
|
218
|
+
let(:function_call_headers) { ServerRescueSpecSupport::FUNCTION_CALL_HEADERS }
|
|
219
|
+
|
|
220
|
+
before do
|
|
221
|
+
Rails.logger = Logger.new(IO::NULL)
|
|
222
|
+
ControllerRequestSpecSupport.boot!
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe "AC3 — function-call request: structured payload, wire contract preserved" do
|
|
226
|
+
it "RecordInvalid on a bare host renders 422 + the full dev-mode payload (A1/A2/A5/A6/A9)",
|
|
227
|
+
:aggregate_failures do
|
|
228
|
+
post "/server_rescue/record_invalid", "{}", function_call_headers
|
|
229
|
+
expect(last_response.status).to eq(422)
|
|
230
|
+
body = JSON.parse(last_response.body)
|
|
231
|
+
expect(body).to include(
|
|
232
|
+
"_ruact_server_action_error" => true,
|
|
233
|
+
"action_name" => "record_invalid",
|
|
234
|
+
"error_class" => "ActiveRecord::RecordInvalid"
|
|
235
|
+
)
|
|
236
|
+
expect(body.fetch("message")).to match(/Title can't be blank/)
|
|
237
|
+
expect(body.fetch("validation_errors")).to include(/Title can't be blank/)
|
|
238
|
+
expect(body.fetch("suggestion")).to eq("Validation failed — check the model's `validates` rules")
|
|
239
|
+
expect(body).to have_key("app_frames")
|
|
240
|
+
expect(body).to have_key("gem_frames")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "ArgumentError renders 500 + structured payload with null suggestion (A9)", :aggregate_failures do
|
|
244
|
+
post "/server_rescue/argument_error", "{}", function_call_headers
|
|
245
|
+
expect(last_response.status).to eq(500)
|
|
246
|
+
body = JSON.parse(last_response.body)
|
|
247
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
248
|
+
expect(body.fetch("action_name")).to eq("argument_error")
|
|
249
|
+
expect(body.fetch("error_class")).to eq("ArgumentError")
|
|
250
|
+
expect(body.fetch("message")).to eq("bad arg")
|
|
251
|
+
expect(body.fetch("suggestion")).to be_nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "RuntimeError renders 500 + structured payload (A9)" do
|
|
255
|
+
post "/server_rescue/runtime_error", "{}", function_call_headers
|
|
256
|
+
expect(last_response.status).to eq(500)
|
|
257
|
+
body = JSON.parse(last_response.body)
|
|
258
|
+
expect(body.fetch("error_class")).to eq("RuntimeError")
|
|
259
|
+
expect(body.fetch("message")).to eq("boom")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it "a non-raising action is untouched by the chain (happy path sanity)" do
|
|
263
|
+
post "/server_rescue/create_ok", "{}", function_call_headers
|
|
264
|
+
expect(last_response.status).to eq(200)
|
|
265
|
+
expect(JSON.parse(last_response.body)).to eq("ok" => true)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
describe "host rescue_from precedence — host wins (A12)" do
|
|
270
|
+
it "RecordInvalid handled by the host renders the host's body, NOT the structured payload" do
|
|
271
|
+
post "/server_rescue/caught_record_invalid", "{}", function_call_headers
|
|
272
|
+
expect(last_response.status).to eq(422)
|
|
273
|
+
body = JSON.parse(last_response.body)
|
|
274
|
+
expect(body.fetch("caught_by_host")).to be(true)
|
|
275
|
+
expect(body).not_to have_key("_ruact_server_action_error")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "RecordInvalid handled by an INHERITED parent handler also wins over the concern (review patch)" do
|
|
279
|
+
post "/server_rescue/inherited_caught_record_invalid", "{}", function_call_headers
|
|
280
|
+
expect(last_response.status).to eq(422)
|
|
281
|
+
body = JSON.parse(last_response.body)
|
|
282
|
+
expect(body.fetch("caught_by_parent")).to be(true)
|
|
283
|
+
expect(body).not_to have_key("_ruact_server_action_error")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
describe "production-mode payload reduction (A8)" do
|
|
288
|
+
before { Ruact.configure { |c| c.dev_error_payload_enabled = false } }
|
|
289
|
+
|
|
290
|
+
it "exposes only the four baseline keys on the wire" do
|
|
291
|
+
post "/server_rescue/record_invalid", "{}", function_call_headers
|
|
292
|
+
expect(last_response.status).to eq(422)
|
|
293
|
+
body = JSON.parse(last_response.body)
|
|
294
|
+
expect(body.keys).to contain_exactly(
|
|
295
|
+
"_ruact_server_action_error",
|
|
296
|
+
"action_name",
|
|
297
|
+
"error_class",
|
|
298
|
+
"message"
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe "strict-boolean handling for dev_error_payload_enabled — review F3 (A10)" do
|
|
304
|
+
it "non-boolean truthy values fall back to the env default (test env → dev mode)" do
|
|
305
|
+
Ruact.configure { |c| c.dev_error_payload_enabled = "false" }
|
|
306
|
+
post "/server_rescue/record_invalid", "{}", function_call_headers
|
|
307
|
+
body = JSON.parse(last_response.body)
|
|
308
|
+
expect(body).to have_key("app_frames")
|
|
309
|
+
expect(body).to have_key("gem_frames")
|
|
310
|
+
expect(body).to have_key("suggestion")
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "non-boolean falsy values fall back to the env default (test env → dev mode)" do
|
|
314
|
+
Ruact.configure { |c| c.dev_error_payload_enabled = 0 }
|
|
315
|
+
post "/server_rescue/record_invalid", "{}", function_call_headers
|
|
316
|
+
expect(JSON.parse(last_response.body)).to have_key("app_frames")
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
describe "server-side logging always fires (A11)" do
|
|
321
|
+
it "logs the [ruact] error line + backtrace at error severity regardless of wire mode" do
|
|
322
|
+
log_io = StringIO.new
|
|
323
|
+
Rails.logger = Logger.new(log_io)
|
|
324
|
+
post "/server_rescue/runtime_error", "{}", function_call_headers
|
|
325
|
+
expect(last_response.status).to eq(500)
|
|
326
|
+
expect(log_io.string).to include(
|
|
327
|
+
"[ruact] server action :runtime_error failed — RuntimeError: boom"
|
|
328
|
+
)
|
|
329
|
+
expect(log_io.string).to include("server_rescue_request_spec") # a backtrace frame
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
describe "CSRF mismatch on a function-call request → 403 + structured payload (A13 / Pitfall #1)" do
|
|
334
|
+
around do |example|
|
|
335
|
+
previous = ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection
|
|
336
|
+
ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection = true
|
|
337
|
+
example.run
|
|
338
|
+
ensure
|
|
339
|
+
ServerRescueSpecSupport::ForgeryServerController.allow_forgery_protection = previous
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "missing X-CSRF-Token produces the structured 403 body with the CSRF suggestion" do
|
|
343
|
+
post "/server_rescue/protected", "{}", function_call_headers
|
|
344
|
+
expect(last_response.status).to eq(403)
|
|
345
|
+
body = JSON.parse(last_response.body)
|
|
346
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
347
|
+
expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
|
|
348
|
+
expect(body.fetch("suggestion")).to eq(
|
|
349
|
+
"CSRF token mismatch — ensure the page was rendered after the most recent server restart " \
|
|
350
|
+
"and the session cookie is intact"
|
|
351
|
+
)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
describe "AC1 — byte-for-byte: non-function-call requests are untouched (C2/C3/D1)" do
|
|
356
|
+
it "a GET page action renders exactly as without the concern (C2)" do
|
|
357
|
+
get "/server_rescue/page"
|
|
358
|
+
expect(last_response.status).to eq(200)
|
|
359
|
+
expect(last_response.body).to eq("plain page body")
|
|
360
|
+
expect(last_response.headers["Content-Type"]).to include("text/plain")
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "a raise on a POST without the function-call Accept propagates — no structured swallow (C3)" do
|
|
364
|
+
# show_exceptions = :none on the shared app → Rails' default handling
|
|
365
|
+
# re-raises to the caller, exactly what a vanilla controller does.
|
|
366
|
+
expect do
|
|
367
|
+
post "/server_rescue/runtime_error", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
368
|
+
end.to raise_error(RuntimeError, "boom")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it "a raise under a browser navigation Accept header also propagates (C3)" do
|
|
372
|
+
expect do
|
|
373
|
+
post "/server_rescue/runtime_error", "{}",
|
|
374
|
+
{
|
|
375
|
+
"CONTENT_TYPE" => "application/json",
|
|
376
|
+
"HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
377
|
+
}
|
|
378
|
+
end.to raise_error(RuntimeError, "boom")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "a raise on a GET with Accept: application/json propagates — stock Rails behavior (review patch)" do
|
|
382
|
+
# Function calls are non-GET by the verb rule (epic contract decision
|
|
383
|
+
# #1); a GET carrying a JSON Accept (fetch() to a page action, API
|
|
384
|
+
# probes) must NOT be swallowed into the structured payload.
|
|
385
|
+
expect do
|
|
386
|
+
get "/server_rescue/erroring_page", {}, { "HTTP_ACCEPT" => "application/json" }
|
|
387
|
+
end.to raise_error(RuntimeError, "boom on GET")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it "a raise on a HEAD with Accept: application/json also propagates (review patch)" do
|
|
391
|
+
expect do
|
|
392
|
+
head "/server_rescue/erroring_page", {}, { "HTTP_ACCEPT" => "application/json" }
|
|
393
|
+
end.to raise_error(RuntimeError, "boom on GET")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
it "a Ruact::ConfigurationError on a function-call request propagates — config stays loud (review patch round 3)" do
|
|
397
|
+
# Configuration invariants (e.g. the upload-guard ordering check) must
|
|
398
|
+
# NOT be folded into a structured 500 just because the request is a
|
|
399
|
+
# function call: a swallowed ConfigurationError reads as a transient
|
|
400
|
+
# server error instead of the setup mistake it is.
|
|
401
|
+
expect do
|
|
402
|
+
post "/server_rescue/config_error", "{}", function_call_headers
|
|
403
|
+
end.to raise_error(Ruact::ConfigurationError, "config invariant violated")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it "a manual UploadTooLargeError raised on a GET propagates — no structured 413 swallow (review patch)" do
|
|
407
|
+
# The UploadTooLargeError exception to the re-raise rule is gated behind
|
|
408
|
+
# the verb check: on a GET (where the guard never fires — D2) a manual
|
|
409
|
+
# raise keeps stock Rails behavior instead of rendering the structured
|
|
410
|
+
# 413, so GET pages stay byte-for-byte untouched (AC1).
|
|
411
|
+
expect do
|
|
412
|
+
get "/server_rescue/upload_error_on_get", {}, { "HTTP_ACCEPT" => "application/json" }
|
|
413
|
+
end.to raise_error(Ruact::UploadTooLargeError)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -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
|