ruact 0.0.11 → 0.0.12

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,785 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Story 9.2 — request-cycle spec for dual-bucket response negotiation on the
4
- # SAME controller action, on the `Ruact::Server` concern. Pins, against REAL
5
- # host-controller routes:
6
- #
7
- # - Bucket 2 (Accept: application/json, non-GET) normal completion → JSON of
8
- # all exposed ivars keyed by name, via ruact_props/Serializable (AC2)
9
- # - Bucket 2 redirect_to → { "$redirect": "<path>" } (AC3)
10
- # - Bucket 2 empty → 204, no body (AC4)
11
- # - Bucket 2 serialization failure → 9.1 chain → 500 structured (AC5)
12
- # - Vary: Accept on EVERY non-GET shape — 200 / 204 / $redirect / Flight (AC6)
13
- # - CSRF on Bucket 2 — host protect_from_forgery (AC7)
14
- # - Bucket 1 (text/x-component) redirect → Flight redirect row, unchanged (AC1)
15
- #
16
- # Mounts on the shared Story-7.9 Rails app; mirrors the 9.1 request-spec pattern.
17
-
18
- require "action_controller/railtie"
19
- require "action_view/railtie"
20
-
21
- require "spec_helper"
22
- require "rack/test"
23
-
24
- require "ruact/controller"
25
- require "ruact/server"
26
- require "active_model"
27
-
28
- require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
29
-
30
- module ServerBucketSpecSupport
31
- # Story 13.3 (FR98) — an ActiveModel record with a presence validation, so a
32
- # host action can exercise the non-exception `if record.valid? … else …`
33
- # happy-failure path that `ruact_errors` round-trips.
34
- class ValidatedPost
35
- include ActiveModel::Model
36
- include ActiveModel::Attributes
37
-
38
- attribute :title, :string
39
-
40
- validates :title, presence: true
41
-
42
- def self.name = "ValidatedPost"
43
- end
44
-
45
- # Serializable model — only :id/:title exposed; :secret must never leak.
46
- class BucketPost
47
- include Ruact::Serializable
48
-
49
- attr_reader :id, :title, :secret
50
-
51
- def initialize(id:, title:, secret:)
52
- @id = id
53
- @title = title
54
- @secret = secret
55
- end
56
-
57
- ruact_props :id, :title
58
- end
59
-
60
- # Non-Serializable record with as_json — under strict_serialization it must
61
- # raise Ruact::SerializationError (AC5).
62
- class UnserializableRecord
63
- def as_json(_opts = nil)
64
- { "id" => 1, "leak" => "everything" }
65
- end
66
- end
67
-
68
- # Bucket-2 host: includes ONLY Ruact::Server (Bucket-2 requests are handled
69
- # by Server#default_render without delegating to Ruact::Controller).
70
- class BucketServerController < ActionController::Base
71
- include Ruact::Server
72
-
73
- def with_ivars
74
- @post = BucketPost.new(id: 1, title: "Hi", secret: "topsecret")
75
- @count = 2
76
- # no explicit render → default_render
77
- end
78
-
79
- def empty_action
80
- # sets no exposed ivars, no redirect → 204 on Bucket 2
81
- end
82
-
83
- def redirecting
84
- redirect_to "/posts/1"
85
- end
86
-
87
- # Review round 1 — cross-host redirect must hit Rails' open-redirect
88
- # protection (raise) instead of silently emitting an external $redirect.
89
- def redirect_external
90
- redirect_to "https://evil.example.com/x"
91
- end
92
-
93
- def redirect_external_allowed
94
- redirect_to "https://allowed.example.com/x", allow_other_host: true
95
- end
96
-
97
- # Review round 1 — a host that sets Vary itself must keep it; Accept is
98
- # appended, not clobbered.
99
- def vary_clobber
100
- response.headers["Vary"] = "Cookie"
101
- @ok = true
102
- end
103
-
104
- # Review round 2 — a host that sets Vary: * (uncacheable wildcard) must keep
105
- # it as-is, not "*, Accept".
106
- def vary_wildcard
107
- response.headers["Vary"] = "*"
108
- @ok = true
109
- end
110
-
111
- def unserializable
112
- @rec = UnserializableRecord.new
113
- end
114
-
115
- # Story 13.3 (FR98) — opted-in FAILED validation: `ruact_errors(post)` after
116
- # an invalid save attempt registers the canonical errors; Bucket-2 injects
117
- # them under the reserved `errors` key alongside the serialized ivars.
118
- def validated_create_invalid
119
- post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
120
- @created = post.valid? # false → populates post.errors
121
- ruact_errors(post)
122
- end
123
-
124
- # Story 13.3 (FR98) — opted-in SUCCESS: the SAME call path yields
125
- # `errors: {}` (success symmetry), so the client reads `result.errors`
126
- # uniformly on both branches.
127
- def validated_create_valid
128
- post = ServerBucketSpecSupport::ValidatedPost.new(title: "Hi")
129
- @created = post.valid? # true → post.errors empty
130
- ruact_errors(post)
131
- end
132
-
133
- # Story 13.3 (FR98) — a stray dev ivar literally named `@errors` must NOT
134
- # clobber the reserved round-trip key: the collector is the source of truth.
135
- def stray_errors_ivar
136
- @errors = "a dev string that must not win"
137
- post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
138
- post.valid?
139
- ruact_errors(post)
140
- end
141
-
142
- # Story 15.0 (F6) — the injection-BYPASS case the dev warning targets:
143
- # registers errors, then renders explicitly on the same branch, so
144
- # `default_render` never runs and the errors vanish from the JSON body.
145
- def opt_out_render
146
- post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
147
- post.valid?
148
- ruact_errors(post)
149
- render json: { "ok" => false }
150
- end
151
-
152
- # Story 15.0 (F6 guardrail a) — the documented-correct Bucket-1 pattern:
153
- # register errors, render the form page again (the template binds the
154
- # `ruact_errors` reader). Requested WITHOUT the function-call Accept.
155
- def failed_with_page_render
156
- post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
157
- post.valid?
158
- ruact_errors(post)
159
- render :new_form
160
- end
161
-
162
- # Story 15.0 (F6 guardrail b) — register errors, then redirect (the
163
- # redirect-back round-trip). Correct on BOTH buckets, must never warn.
164
- def failed_with_redirect
165
- post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
166
- post.valid?
167
- ruact_errors(post)
168
- redirect_to "/posts/new"
169
- end
170
-
171
- # Story 15.0 (F6 guardrail d) — explicit render with an UNTOUCHED collector.
172
- def untouched_explicit_render
173
- render json: { "ok" => true }
174
- end
175
-
176
- # Story 15.5 (FR109) — a plain Rails action on a `Ruact::Server` controller:
177
- # a GET that renders plain text, so it is neither a function call nor a ruact
178
- # page render. The dev shape-log must stay SILENT on it (non-negotiated).
179
- def plain_rails
180
- render plain: "just rails"
181
- end
182
- end
183
-
184
- # Review round 2 — an auth-style before_action that redirects on a Bucket-2
185
- # call: the response is performed in the before_action, so after-callbacks are
186
- # skipped — Vary must still be present (set by a before_action too).
187
- class BucketBeforeRedirectController < ActionController::Base
188
- include Ruact::Server
189
-
190
- before_action :bounce
191
-
192
- def never_runs
193
- @ok = true
194
- end
195
-
196
- private
197
-
198
- def bounce
199
- redirect_to "/login"
200
- end
201
- end
202
-
203
- # Bucket-1 regression host: BOTH concerns, so a non-function-call request
204
- # delegates Server#redirect_to → super → Ruact::Controller's Flight row.
205
- class BucketDualController < ActionController::Base
206
- include Ruact::Controller
207
- include Ruact::Server
208
-
209
- def redirecting
210
- redirect_to "/posts/1"
211
- end
212
- end
213
-
214
- # CSRF-enforcing Bucket-2 host (AC7) — forgery flipped on per-example.
215
- class BucketForgeryController < ActionController::Base
216
- include Ruact::Server
217
-
218
- protect_from_forgery with: :exception
219
-
220
- def create_protected
221
- @ok = true
222
- end
223
-
224
- # GET is exempt from CSRF verification — emits a per-request token masked
225
- # against the session master, for the valid-token round-trip (AC7).
226
- def csrf_token
227
- render json: { token: form_authenticity_token }
228
- end
229
- end
230
-
231
- # Story 10.0 (AC5) — the scaffold's resource-controller shape: includes BOTH
232
- # Ruact::Controller and Ruact::Server (ApplicationController contributes
233
- # Controller; the resource controller adds Server). An ivar-only GET page
234
- # action (implicit `default_render`) backed by a conventional template must
235
- # flow Server#default_render → super (GET is never a function call) → the
236
- # now-fixed Controller#default_render → HTML shell, NOT a 500.
237
- class ServerImplicitController < ActionController::Base
238
- include Ruact::Controller
239
- include Ruact::Server
240
-
241
- def show; end
242
- end
243
- end
244
-
245
- # Story 10.0 (AC5) — conventional template under the shared app root so the
246
- # Server-including controller's implicit GET reaches Controller#default_render.
247
- ControllerRequestSpecSupport.write_view(
248
- "server_bucket_spec_support/server_implicit", "show", <<~ERB
249
- <div>
250
- <DemoButton label={"hi"} />
251
- </div>
252
- ERB
253
- )
254
-
255
- # Story 15.0 (F6 guardrail a) — the re-rendered form page for the documented
256
- # Bucket-1 `ruact_errors(record); render :new_form` pattern. No component tags
257
- # on purpose (the guardrail is about the warn predicate, not the pipeline).
258
- ControllerRequestSpecSupport.write_view(
259
- "server_bucket_spec_support/bucket_server", "new_form", <<~ERB
260
- <div>the form again</div>
261
- ERB
262
- )
263
-
264
- if defined?(ControllerRequestSpecSupport) &&
265
- !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_bucket_routes_appended)
266
- ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_bucket_routes_appended, true)
267
- ControllerRequestSpecSupport.app_class.routes.append do
268
- post "/bucket/with_ivars", to: "server_bucket_spec_support/bucket_server#with_ivars"
269
- post "/bucket/empty", to: "server_bucket_spec_support/bucket_server#empty_action"
270
- post "/bucket/redirecting", to: "server_bucket_spec_support/bucket_server#redirecting"
271
- post "/bucket/redirect_external", to: "server_bucket_spec_support/bucket_server#redirect_external"
272
- post "/bucket/redirect_external_allowed", to: "server_bucket_spec_support/bucket_server#redirect_external_allowed"
273
- post "/bucket/vary_clobber", to: "server_bucket_spec_support/bucket_server#vary_clobber"
274
- post "/bucket/vary_wildcard", to: "server_bucket_spec_support/bucket_server#vary_wildcard"
275
- post "/bucket/before_redirect", to: "server_bucket_spec_support/bucket_before_redirect#never_runs"
276
- post "/bucket/unserializable", to: "server_bucket_spec_support/bucket_server#unserializable"
277
- post "/bucket/validated_create_invalid", to: "server_bucket_spec_support/bucket_server#validated_create_invalid"
278
- post "/bucket/validated_create_valid", to: "server_bucket_spec_support/bucket_server#validated_create_valid"
279
- post "/bucket/stray_errors_ivar", to: "server_bucket_spec_support/bucket_server#stray_errors_ivar"
280
- # Story 15.0 (F6) — the warning truth-table routes.
281
- post "/bucket/opt_out_render", to: "server_bucket_spec_support/bucket_server#opt_out_render"
282
- post "/bucket/failed_with_page_render",
283
- to: "server_bucket_spec_support/bucket_server#failed_with_page_render"
284
- post "/bucket/failed_with_redirect", to: "server_bucket_spec_support/bucket_server#failed_with_redirect"
285
- post "/bucket/untouched_explicit_render",
286
- to: "server_bucket_spec_support/bucket_server#untouched_explicit_render"
287
- # Story 15.5 (FR109) — a plain Rails GET on a Server controller (non-negotiated).
288
- get "/bucket/plain_rails", to: "server_bucket_spec_support/bucket_server#plain_rails"
289
- post "/bucket/dual_redirecting", to: "server_bucket_spec_support/bucket_dual#redirecting"
290
- post "/bucket/protected", to: "server_bucket_spec_support/bucket_forgery#create_protected"
291
- get "/bucket/csrf_token", to: "server_bucket_spec_support/bucket_forgery#csrf_token"
292
- # Story 10.0 (AC5) — Server-including controller's implicit GET page action.
293
- get "/server-implicit/show", to: "server_bucket_spec_support/server_implicit#show"
294
- end
295
- end
296
-
297
- RSpec.describe "Story 9.2: Ruact::Server dual-bucket response negotiation", :story_9_2 do
298
- include Rack::Test::Methods
299
-
300
- let(:app_class) { ControllerRequestSpecSupport.app_class }
301
- let(:app) { app_class.instance }
302
-
303
- let(:json_headers) { { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" } }
304
- let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
305
-
306
- before do
307
- Rails.logger = Logger.new(IO::NULL)
308
- ControllerRequestSpecSupport.boot!
309
- # Story 10.0 (AC5) — pin Rails.root so the Server-path implicit-render
310
- # template (`Rails.root/app/views`) is found regardless of full-suite
311
- # ordering leftovers (see controller_request_spec.rb for the rationale).
312
- Rails.root = ControllerRequestSpecSupport.app_root
313
- end
314
-
315
- def reset_config
316
- Ruact.instance_variable_set(:@config, nil)
317
- Ruact.instance_variable_set(:@configured_at_least_once, false)
318
- end
319
-
320
- describe "Bucket 2 — normal completion serializes all exposed ivars (AC2)" do
321
- it "renders 200 + a JSON object keyed by ivar name, ruact_props only (no secret)" do
322
- post "/bucket/with_ivars", "{}", json_headers
323
- expect(last_response.status).to eq(200)
324
- expect(last_response.headers["Content-Type"]).to include("application/json")
325
- body = JSON.parse(last_response.body)
326
- expect(body).to eq("post" => { "id" => 1, "title" => "Hi" }, "count" => 2)
327
- expect(body.fetch("post")).not_to have_key("secret")
328
- end
329
- end
330
-
331
- describe "Bucket 2 — redirect_to surfaces as $redirect (AC3)" do
332
- it "renders 200 + { \"$redirect\": \"<path>\" } instead of a 302 / Flight row" do
333
- post "/bucket/redirecting", "{}", json_headers
334
- expect(last_response.status).to eq(200)
335
- expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/1")
336
- end
337
- end
338
-
339
- describe "Bucket 2 — redirect open-redirect protection (review round 1)" do
340
- # Turn Rails' open-redirect protection ON for this app (the bare test app
341
- # doesn't `load_defaults`, so it defaults to log-and-allow). `raise_on_open_redirects`
342
- # forces the raise path on both Rails 7.0 and 8.x.
343
- around do |example|
344
- previous = ActionController::Base.raise_on_open_redirects
345
- ActionController::Base.raise_on_open_redirects = true
346
- example.run
347
- ensure
348
- ActionController::Base.raise_on_open_redirects = previous
349
- end
350
-
351
- it "a cross-host redirect_to raises (open-redirect protection) → 500 structured, matching Rails" do
352
- post "/bucket/redirect_external", "{}", json_headers
353
- expect(last_response.status).to eq(500)
354
- expect(JSON.parse(last_response.body).fetch("error_class")).to match(/RedirectError/)
355
- end
356
-
357
- it "honors allow_other_host: true — emits the absolute $redirect" do
358
- post "/bucket/redirect_external_allowed", "{}", json_headers
359
- expect(last_response.status).to eq(200)
360
- expect(JSON.parse(last_response.body)).to eq("$redirect" => "https://allowed.example.com/x")
361
- end
362
- end
363
-
364
- describe "Vary preserves a host-set value (review round 1)" do
365
- it "appends Accept to a Vary the action set, never clobbering it" do
366
- post "/bucket/vary_clobber", "{}", json_headers
367
- vary = last_response.headers["Vary"]
368
- expect(vary).to match(/\bCookie\b/)
369
- expect(vary).to match(/\bAccept\b/)
370
- end
371
- end
372
-
373
- describe "Vary edge cases (review round 2)" do
374
- it "is set on a $redirect performed by a before_action (after-callbacks skipped)" do
375
- post "/bucket/before_redirect", "{}", json_headers
376
- expect(JSON.parse(last_response.body)).to eq("$redirect" => "/login")
377
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
378
- end
379
-
380
- it "preserves a host Vary: * wildcard as-is (does not produce '*, Accept')" do
381
- post "/bucket/vary_wildcard", "{}", json_headers
382
- expect(last_response.headers["Vary"]).to eq("*")
383
- end
384
- end
385
-
386
- describe "Bucket 2 — empty action → 204 (AC4)" do
387
- it "returns 204 No Content with an empty body" do
388
- post "/bucket/empty", "{}", json_headers
389
- expect(last_response.status).to eq(204)
390
- expect(last_response.body).to eq("")
391
- end
392
- end
393
-
394
- describe "Bucket 2 — serialization failure routes through the 9.1 chain as 500 (AC5/AC9)" do
395
- before do
396
- # Re-prime AFTER spec_helper's global before resets @config, so strict
397
- # sticks for the example body (local before runs after global before).
398
- reset_config
399
- Ruact.configure { |c| c.strict_serialization = true }
400
- end
401
-
402
- it "raises Ruact::SerializationError → 500 structured payload", :aggregate_failures do
403
- post "/bucket/unserializable", "{}", json_headers
404
- expect(last_response.status).to eq(500)
405
- body = JSON.parse(last_response.body)
406
- expect(body.fetch("_ruact_server_action_error")).to be(true)
407
- expect(body.fetch("error_class")).to eq("Ruact::SerializationError")
408
- expect(body.fetch("message")).to match(/Cannot serialize/)
409
- end
410
- end
411
-
412
- describe "Vary: Accept on every non-GET shape (AC6)" do
413
- it "is set on the 200 ivar-JSON response" do
414
- post "/bucket/with_ivars", "{}", json_headers
415
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
416
- end
417
-
418
- it "is set on the 204 response" do
419
- post "/bucket/empty", "{}", json_headers
420
- expect(last_response.status).to eq(204)
421
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
422
- end
423
-
424
- it "is set on the $redirect response" do
425
- post "/bucket/redirecting", "{}", json_headers
426
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
427
- end
428
-
429
- it "is set on the Bucket-1 Flight response too" do
430
- post "/bucket/dual_redirecting", "{}", flight_headers
431
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
432
- end
433
- end
434
-
435
- describe "Bucket 1 — form/navigation Flight path unchanged (AC1)" do
436
- it "a text/x-component redirect still emits the Flight redirect row (no $redirect JSON)" do
437
- post "/bucket/dual_redirecting", "{}", flight_headers
438
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
439
- expect(last_response.body).to eq("0:#{JSON.generate({ 'redirectUrl' => '/posts/1',
440
- 'redirectType' => 'push' })}\n")
441
- end
442
- end
443
-
444
- describe "CSRF on Bucket 2 — host protect_from_forgery (AC7)" do
445
- around do |example|
446
- previous = ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection
447
- ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = true
448
- example.run
449
- ensure
450
- ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = previous
451
- end
452
-
453
- it "valid X-CSRF-Token → 200 + serialized ivars (full round-trip)" do
454
- get "/bucket/csrf_token"
455
- token = JSON.parse(last_response.body).fetch("token")
456
- post "/bucket/protected", "{}", json_headers.merge("HTTP_X_CSRF_TOKEN" => token)
457
- expect(last_response.status).to eq(200)
458
- expect(JSON.parse(last_response.body)).to eq("ok" => true)
459
- end
460
-
461
- it "missing X-CSRF-Token → 403 structured (via the 9.1 chain)" do
462
- post "/bucket/protected", "{}", json_headers
463
- expect(last_response.status).to eq(403)
464
- expect(JSON.parse(last_response.body).fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
465
- end
466
-
467
- it "invalid X-CSRF-Token → 403 structured" do
468
- post "/bucket/protected", "{}", json_headers.merge("HTTP_X_CSRF_TOKEN" => "nope-not-valid")
469
- expect(last_response.status).to eq(403)
470
- end
471
-
472
- context "with forgery protection OFF (API mode)" do
473
- around do |example|
474
- ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = false
475
- example.run
476
- end
477
-
478
- it "accepts a tokenless request and serializes ivars" do
479
- post "/bucket/protected", "{}", json_headers
480
- expect(last_response.status).to eq(200)
481
- expect(JSON.parse(last_response.body)).to eq("ok" => true)
482
- end
483
- end
484
- end
485
-
486
- # Story 13.3 (FR98) — the Bucket-2 (imperative `await`) half of the
487
- # Inertia-style validation `errors` round-trip (AC3).
488
- describe "Story 13.3: Bucket-2 errors round-trip (FR98)", :story_13_3 do
489
- it "FAILED validation injects the canonical errors under the reserved `errors` key" do
490
- post "/bucket/validated_create_invalid", "{}", json_headers
491
- expect(last_response.status).to eq(200)
492
- body = JSON.parse(last_response.body)
493
- expect(body).to eq("created" => false, "errors" => { "title" => ["Title can't be blank"] })
494
- end
495
-
496
- it "SUCCESS yields `errors: {}` through the same call path (success symmetry)" do
497
- post "/bucket/validated_create_valid", "{}", json_headers
498
- expect(last_response.status).to eq(200)
499
- body = JSON.parse(last_response.body)
500
- expect(body).to eq("created" => true, "errors" => {})
501
- end
502
-
503
- it "is set with Vary: Accept like every other non-GET shape" do
504
- post "/bucket/validated_create_valid", "{}", json_headers
505
- expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
506
- end
507
-
508
- it "the collector wins over a stray dev ivar literally named @errors" do
509
- post "/bucket/stray_errors_ivar", "{}", json_headers
510
- expect(last_response.status).to eq(200)
511
- body = JSON.parse(last_response.body)
512
- expect(body.fetch("errors")).to eq("title" => ["Title can't be blank"])
513
- end
514
-
515
- it "preserves the 9.2 untouched-collector contract: an empty opted-out action still 204s" do
516
- # `empty_action` never calls `ruact_errors` → no `errors` key, 204 No Content.
517
- post "/bucket/empty", "{}", json_headers
518
- expect(last_response.status).to eq(204)
519
- expect(last_response.body).to eq("")
520
- end
521
-
522
- it "preserves the 9.2 untouched-collector contract: a populated action carries NO errors key" do
523
- # `with_ivars` sets ivars but never opts in → body has no `errors` key.
524
- post "/bucket/with_ivars", "{}", json_headers
525
- expect(JSON.parse(last_response.body)).not_to have_key("errors")
526
- end
527
-
528
- # AC5(e) coexistence: the RAISED `ActiveRecord::RecordInvalid` path still
529
- # renders the structured 422 error payload — proven (unchanged) by
530
- # server_rescue_request_spec.rb's `record_invalid` example. 13.3 is the
531
- # NON-raised 200 path above; the two contracts stay distinct.
532
- end
533
-
534
- # Story 15.0 (F6) — the dev-mode injection opt-out warning truth table.
535
- # Fires ONLY on: development + function-call request + touched collector +
536
- # explicit host render. Silent on every documented-correct pattern.
537
- describe "Story 15.0: dev-mode ruact_errors injection opt-out warning (F6)", :story_15_0 do
538
- # The distinctive fragment of the F6 message (deliberately distinct from
539
- # any other `[ruact]` log line, incl. the future 15.5 bucket log).
540
- let(:warning_re) { /\[ruact\] .*called `ruact_errors` and then rendered explicitly/ }
541
- let(:html_form_headers) do
542
- { "CONTENT_TYPE" => "application/x-www-form-urlencoded", "HTTP_ACCEPT" => "text/html" }
543
- end
544
-
545
- before do
546
- allow(Rails.logger).to receive(:warn)
547
- end
548
-
549
- # NOTE: the suite's default Rails.env is "development" (RAILS_ENV unset),
550
- # so the dev examples stub it EXPLICITLY anyway (they must not depend on
551
- # the suite default) and the non-dev example stubs a non-dev env.
552
- def stub_env(name)
553
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
554
- end
555
-
556
- def stub_development_env
557
- stub_env("development")
558
- end
559
-
560
- it "warns exactly once on an explicit render after ruact_errors on a function-call request" do
561
- stub_development_env
562
- post "/bucket/opt_out_render", "{}", json_headers
563
-
564
- expect(Rails.logger).to have_received(:warn).with(
565
- a_string_matching(/\[ruact\] .*BucketServerController#opt_out_render.*ruact_errors.*NOT.*injected/m)
566
- ).once
567
- end
568
-
569
- it "names the fix in the message (fall through, page-render binding, or redirect)" do
570
- stub_development_env
571
- post "/bucket/opt_out_render", "{}", json_headers
572
-
573
- expect(Rails.logger).to have_received(:warn).with(
574
- a_string_matching(/fall.*through.*errors=\{ruact_errors\}.*redirect_to/m)
575
- )
576
- end
577
-
578
- it "keeps the response byte-identical whether the warning fires or not (log-only)" do
579
- stub_env("production")
580
- post "/bucket/opt_out_render", "{}", json_headers
581
- silent_status = last_response.status
582
- silent_body = last_response.body
583
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
584
-
585
- stub_development_env
586
- post "/bucket/opt_out_render", "{}", json_headers
587
-
588
- expect(Rails.logger).to have_received(:warn).with(a_string_matching(warning_re))
589
- expect(last_response.status).to eq(silent_status)
590
- expect(last_response.body).to eq(silent_body)
591
- expect(JSON.parse(last_response.body)).to eq("ok" => false)
592
- end
593
-
594
- it "guardrail (a): silent on the documented Bucket-1 `ruact_errors(record); render :new` pattern" do
595
- stub_development_env
596
- post "/bucket/failed_with_page_render", "post[title]=", html_form_headers
597
-
598
- expect(last_response.status).to eq(200)
599
- expect(last_response.body).to include("the form again")
600
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
601
- end
602
-
603
- it "guardrail (b): silent on `ruact_errors(record); redirect_to` on a function-call request" do
604
- stub_development_env
605
- post "/bucket/failed_with_redirect", "{}", json_headers
606
-
607
- expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/new")
608
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
609
- end
610
-
611
- it "guardrail (b): silent on `ruact_errors(record); redirect_to` on a Bucket-1 request" do
612
- stub_development_env
613
- post "/bucket/failed_with_redirect", "post[title]=", html_form_headers
614
-
615
- expect(last_response.status).to eq(302)
616
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
617
- end
618
-
619
- it "guardrail (c): silent on the fall-through itself (errors were injected)" do
620
- stub_development_env
621
- post "/bucket/validated_create_invalid", "{}", json_headers
622
-
623
- expect(JSON.parse(last_response.body).fetch("errors")).to eq("title" => ["Title can't be blank"])
624
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
625
- end
626
-
627
- it "guardrail (d): silent on an explicit render when the collector was never touched" do
628
- stub_development_env
629
- post "/bucket/untouched_explicit_render", "{}", json_headers
630
-
631
- expect(JSON.parse(last_response.body)).to eq("ok" => true)
632
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
633
- end
634
-
635
- it "never warns outside development (the bypass case in the test env stays silent)" do
636
- stub_env("test")
637
- post "/bucket/opt_out_render", "{}", json_headers
638
-
639
- expect(last_response.status).to eq(200)
640
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
641
- end
642
-
643
- it "never warns in production either" do
644
- stub_env("production")
645
- post "/bucket/opt_out_render", "{}", json_headers
646
-
647
- expect(last_response.status).to eq(200)
648
- expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
649
- end
650
- end
651
-
652
- # Story 10.0 (AC5) — the Server-path inherits the default_render graceful
653
- # degradation fix. A GET is never a function call, so Server#default_render
654
- # delegates to super → the fixed Controller#default_render.
655
- describe "Story 10.0: Server path inherits non-HTML Accept graceful degradation (AC5)", :story_10_0 do
656
- it "GET with Accept: */* returns the HTML shell, not a 500" do
657
- get "/server-implicit/show", {}, { "HTTP_ACCEPT" => "*/*" }
658
- expect(last_response.status).to(eq(200),
659
- "expected 200, got #{last_response.status} body=#{last_response.body[0, 400]}")
660
- expect(last_response.headers["Content-Type"]).to include("text/html")
661
- expect(last_response.body).to include("DemoButton")
662
- expect(last_response.body).not_to include("__ruact_component__ called outside a ruact_render flow")
663
- end
664
-
665
- it "GET with Accept: text/x-component returns a Flight payload (function-call path untouched)" do
666
- get "/server-implicit/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
667
- expect(last_response.status).to eq(200)
668
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
669
- end
670
- end
671
-
672
- # Story 15.5 (FR109) — the dev-mode response-shape legibility log. One
673
- # `[ruact]` line per ruact-NEGOTIATED request naming the chosen bucket + the
674
- # deciding signal. Log-only (byte-identical), dev-gated, SILENT on
675
- # non-negotiated (plain Rails) responses. Mirrors the F6 warning test shape.
676
- describe "Story 15.5: dev-mode response-shape log (FR109)", :story_15_5 do
677
- # The distinctive fragment of the 15.5 log — deliberately distinct from the
678
- # F6 warning line (which matches `called \`ruact_errors\` and then rendered`).
679
- let(:shape_log_re) { /\[ruact\] .* — .* → / }
680
- let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
681
- let(:html_get_headers) { { "HTTP_ACCEPT" => "*/*" } }
682
-
683
- before do
684
- # The suite default env is development (RAILS_ENV unset); dev examples
685
- # still stub it explicitly so they never depend on the suite default.
686
- allow(Rails.logger).to receive(:info)
687
- end
688
-
689
- def stub_env(name)
690
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
691
- end
692
-
693
- def stub_development_env
694
- stub_env("development")
695
- end
696
-
697
- it "logs the function-call JSON bucket once with the deciding signal (AC2)" do
698
- stub_development_env
699
- post "/bucket/with_ivars", "{}", json_headers
700
-
701
- expect(Rails.logger).to have_received(:info).with(
702
- "[ruact] ServerBucketSpecSupport::BucketServerController#with_ivars — " \
703
- "Accept: application/json + POST → function-call JSON"
704
- ).once
705
- end
706
-
707
- it "names the 204 (no ivars) sub-shape" do
708
- stub_development_env
709
- post "/bucket/empty", "{}", json_headers
710
-
711
- expect(last_response.status).to eq(204)
712
- expect(Rails.logger).to have_received(:info).with(
713
- a_string_matching(%r{#empty_action — Accept: application/json \+ POST → function-call JSON \(204, no ivars\)})
714
- ).once
715
- end
716
-
717
- it "names the $redirect sub-shape" do
718
- stub_development_env
719
- post "/bucket/redirecting", "{}", json_headers
720
-
721
- expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/1")
722
- expect(Rails.logger).to have_received(:info).with(
723
- a_string_matching(%r{#redirecting — Accept: application/json \+ POST → function-call \$redirect})
724
- ).once
725
- end
726
-
727
- it "logs the Flight-page bucket on a text/x-component page render (AC2)" do
728
- stub_development_env
729
- get "/server-implicit/show", {}, flight_headers
730
-
731
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
732
- expect(Rails.logger).to have_received(:info).with(
733
- "[ruact] ServerBucketSpecSupport::ServerImplicitController#show — " \
734
- "Accept: text/x-component → Flight page"
735
- ).once
736
- end
737
-
738
- it "logs the HTML-shell bucket on an HTML-acceptable page GET (AC2)" do
739
- stub_development_env
740
- get "/server-implicit/show", {}, html_get_headers
741
-
742
- expect(last_response.headers["Content-Type"]).to include("text/html")
743
- expect(Rails.logger).to have_received(:info).with(
744
- "[ruact] ServerBucketSpecSupport::ServerImplicitController#show — " \
745
- "HTML-acceptable Accept, no Flight header → HTML shell"
746
- ).once
747
- end
748
-
749
- it "is SILENT on a plain Rails action on the same controller (non-negotiated, AC2)" do
750
- stub_development_env
751
- get "/bucket/plain_rails"
752
-
753
- expect(last_response.status).to eq(200)
754
- expect(last_response.body).to eq("just rails")
755
- expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
756
- end
757
-
758
- it "is SILENT outside development and byte-identical to the dev response (log-only, AC2)",
759
- :aggregate_failures do
760
- stub_env("production")
761
- post "/bucket/with_ivars", "{}", json_headers
762
- prod_status = last_response.status
763
- prod_body = last_response.body
764
- prod_ctype = last_response.headers["Content-Type"]
765
- prod_vary = last_response.headers["Vary"]
766
- expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
767
-
768
- stub_development_env
769
- post "/bucket/with_ivars", "{}", json_headers
770
- expect(Rails.logger).to have_received(:info).with(a_string_matching(shape_log_re))
771
- expect(last_response.status).to eq(prod_status)
772
- expect(last_response.body).to eq(prod_body)
773
- expect(last_response.headers["Content-Type"]).to eq(prod_ctype)
774
- expect(last_response.headers["Vary"]).to eq(prod_vary)
775
- end
776
-
777
- it "never logs in the test environment either" do
778
- stub_env("test")
779
- post "/bucket/with_ivars", "{}", json_headers
780
-
781
- expect(last_response.status).to eq(200)
782
- expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
783
- end
784
- end
785
- end