ruact 0.0.4 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -30,10 +30,12 @@ module Ruact
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Minimal test double — `headers`
|
|
34
|
-
# `format` must be a public struct member (not
|
|
35
|
-
# verify_partial_doubles can stub it
|
|
36
|
-
|
|
33
|
+
# Minimal test double — `headers`, `format`, and `accepts` needed for the
|
|
34
|
+
# methods under test. `format` must be a public struct member (not
|
|
35
|
+
# Kernel#format) so that verify_partial_doubles can stub it. Story 10.0:
|
|
36
|
+
# `#default_render` now keys off `request.accepts` (HTML acceptability), so
|
|
37
|
+
# `accepts` is a settable member too.
|
|
38
|
+
let(:fake_request) { Struct.new(:headers, :format, :accepts).new({}, nil, []) }
|
|
37
39
|
let(:controller) { test_class.new(fake_request) }
|
|
38
40
|
|
|
39
41
|
describe "#ruact_manifest" do
|
|
@@ -71,30 +73,41 @@ module Ruact
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
describe "#default_render" do
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
# Story 10.0 — the activation predicate keys off HTML *acceptability*
|
|
77
|
+
# (`request.accepts`), not `request.format.html?`. `request.format.html?`
|
|
78
|
+
# is false for a `*/*` wildcard, which 500'd those requests pre-10.0.
|
|
77
79
|
before do
|
|
78
80
|
allow(controller).to receive(:ruact_template_exists?).and_return(true)
|
|
79
81
|
allow(controller).to receive(:ruact_render)
|
|
80
82
|
end
|
|
81
83
|
|
|
82
|
-
it "calls ruact_render when
|
|
83
|
-
|
|
84
|
+
it "calls ruact_render when the client accepts text/html and template exists (AC#1)" do
|
|
85
|
+
fake_request.accepts = [Mime[:html]]
|
|
86
|
+
controller.send(:default_render)
|
|
87
|
+
expect(controller).to have_received(:ruact_render)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "calls ruact_render for a */* wildcard Accept — Mime::ALL (Story 10.0 AC#1)" do
|
|
91
|
+
fake_request.accepts = [Mime::ALL]
|
|
92
|
+
controller.send(:default_render)
|
|
93
|
+
expect(controller).to have_received(:ruact_render)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "calls ruact_render when Accept is blank (defaults to HTML) (Story 10.0 AC#1)" do
|
|
97
|
+
fake_request.accepts = []
|
|
84
98
|
controller.send(:default_render)
|
|
85
99
|
expect(controller).to have_received(:ruact_render)
|
|
86
100
|
end
|
|
87
101
|
|
|
88
|
-
it "calls ruact_render for RSC requests (text/x-component) even
|
|
89
|
-
|
|
102
|
+
it "calls ruact_render for RSC requests (text/x-component) even when HTML is unacceptable (AC#1)" do
|
|
103
|
+
fake_request.accepts = [Mime[:json]]
|
|
90
104
|
fake_request.headers["Ruact-Request"] = "1"
|
|
91
105
|
controller.send(:default_render)
|
|
92
106
|
expect(controller).to have_received(:ruact_render)
|
|
93
107
|
end
|
|
94
108
|
|
|
95
|
-
it "does NOT call ruact_render
|
|
96
|
-
|
|
97
|
-
allow(controller).to receive(:ruact_render)
|
|
109
|
+
it "does NOT call ruact_render for a concrete non-HTML Accept that is not RSC (AC#4 — FR26)" do
|
|
110
|
+
fake_request.accepts = [Mime[:json]]
|
|
98
111
|
begin
|
|
99
112
|
controller.send(:default_render)
|
|
100
113
|
rescue StandardError
|
|
@@ -105,7 +118,7 @@ module Ruact
|
|
|
105
118
|
|
|
106
119
|
it "does NOT call ruact_render when no template exists" do
|
|
107
120
|
allow(controller).to receive(:ruact_template_exists?).and_return(false)
|
|
108
|
-
|
|
121
|
+
fake_request.accepts = [Mime[:html]]
|
|
109
122
|
begin
|
|
110
123
|
controller.send(:default_render)
|
|
111
124
|
rescue StandardError
|
|
@@ -191,8 +204,11 @@ module Ruact
|
|
|
191
204
|
end
|
|
192
205
|
|
|
193
206
|
describe "#ruact_html_shell" do
|
|
194
|
-
#
|
|
195
|
-
|
|
207
|
+
# Story 14.2 — the entry `<script>` tags are emitted by the mixed-in
|
|
208
|
+
# ViewHelper#ruact_vite_tags (which needs Rails.env / a live Vite probe).
|
|
209
|
+
# Stub it to "" so we test the shell structure + the __FLIGHT_DATA script
|
|
210
|
+
# (still emitted by ruact_js_assets) in isolation.
|
|
211
|
+
before { allow(controller).to receive(:ruact_vite_tags).and_return("") }
|
|
196
212
|
|
|
197
213
|
let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
|
|
198
214
|
|
|
@@ -243,6 +259,12 @@ module Ruact
|
|
|
243
259
|
end
|
|
244
260
|
let(:csrf_controller) { csrf_test_class.new(fake_request) }
|
|
245
261
|
|
|
262
|
+
# Story 14.2 — csrf_controller is a separate instance from `controller`,
|
|
263
|
+
# so the outer `before` stub does not reach it. Stub its entry-tag
|
|
264
|
+
# emission too (the real path would hit Rails.root in a non-booted env),
|
|
265
|
+
# keeping these CSRF tests order-independent.
|
|
266
|
+
before { allow(csrf_controller).to receive(:ruact_vite_tags).and_return("") }
|
|
267
|
+
|
|
246
268
|
it "embeds <meta name=\"csrf-token\" content=\"...\"> when the host exposes form_authenticity_token" do
|
|
247
269
|
allow(csrf_controller).to receive(:form_authenticity_token).and_return("test-csrf-token-value")
|
|
248
270
|
html = csrf_controller.send(:ruact_html_shell, payload)
|
|
@@ -279,322 +301,37 @@ module Ruact
|
|
|
279
301
|
end
|
|
280
302
|
end
|
|
281
303
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
it "registers an action in Ruact.action_registry with the correct kind, controller, and block" do
|
|
290
|
-
klass = Class.new do
|
|
291
|
-
def self.name = "ExampleController"
|
|
292
|
-
include Ruact::Controller
|
|
293
|
-
|
|
294
|
-
ruact_action(:create_post) { |params| "created #{params[:title]}" }
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
entry = Ruact.action_registry.entries[:create_post]
|
|
298
|
-
expect(entry).not_to be_nil
|
|
299
|
-
expect(entry.ruby_symbol).to eq(:create_post)
|
|
300
|
-
expect(entry.js_identifier).to eq("createPost")
|
|
301
|
-
expect(entry.kind).to eq(:action)
|
|
302
|
-
expect(entry.controller).to be(klass)
|
|
303
|
-
expect(entry.block).to be_a(Proc)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
it "defines the action symbol as a PUBLIC method (Rails action dispatch requires public) " \
|
|
307
|
-
"with a thread-local guard that rejects non-endpoint invocations (review-batch 1 2026-05-14)" do
|
|
308
|
-
klass = Class.new do
|
|
309
|
-
def self.name = "ExampleController"
|
|
310
|
-
include Ruact::Controller
|
|
311
|
-
|
|
312
|
-
ruact_action(:create_post) { |params| "echo #{params[:title]}" }
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
instance = klass.allocate
|
|
316
|
-
# Public (so ActionController#process can dispatch it through the
|
|
317
|
-
# before_action chain when scoped `only: :create_post`).
|
|
318
|
-
expect(instance.respond_to?(:create_post)).to be(true)
|
|
319
|
-
# Direct call without the thread-local sentinel raises — closes the
|
|
320
|
-
# wildcard-route exposure where a host's `get ":controller/:action"`
|
|
321
|
-
# could otherwise reach the action via GET.
|
|
322
|
-
expect { instance.send(:create_post) }.to raise_error(Ruact::Error, /can only be invoked through POST/)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
it "raises ArgumentError when no block is given" do
|
|
326
|
-
expect do
|
|
327
|
-
Class.new do
|
|
328
|
-
def self.name = "BadController"
|
|
329
|
-
include Ruact::Controller
|
|
330
|
-
|
|
331
|
-
ruact_action(:create_post)
|
|
332
|
-
end
|
|
333
|
-
end.to raise_error(ArgumentError, /requires a block/)
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
it "raises Ruact::ConfigurationError with the AC7 prefix on invalid symbol shape" do
|
|
337
|
-
expect do
|
|
338
|
-
Class.new do
|
|
339
|
-
def self.name = "BadController"
|
|
340
|
-
include Ruact::Controller
|
|
341
|
-
|
|
342
|
-
ruact_action(:CreatePost) { |_p| nil }
|
|
343
|
-
end
|
|
344
|
-
end.to raise_error(Ruact::ConfigurationError, /invalid server-function symbol :CreatePost in BadController/)
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
it "raises Ruact::ConfigurationError with the AC7 collision wording on within-registry duplicate js_identifier" do
|
|
348
|
-
expect do
|
|
349
|
-
Class.new do
|
|
350
|
-
def self.name = "CollidingController"
|
|
351
|
-
include Ruact::Controller
|
|
352
|
-
|
|
353
|
-
ruact_action(:foo_bar) { |_p| nil }
|
|
354
|
-
ruact_action(:foo__bar) { |_p| nil }
|
|
355
|
-
end
|
|
356
|
-
end.to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
it "raises Ruact::ConfigurationError when the symbol maps to a reserved JS word" do
|
|
360
|
-
expect do
|
|
361
|
-
Class.new do
|
|
362
|
-
def self.name = "BadController"
|
|
363
|
-
include Ruact::Controller
|
|
364
|
-
|
|
365
|
-
ruact_action(:delete) { |_p| nil }
|
|
366
|
-
end
|
|
367
|
-
end.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
it "raises Ruact::ConfigurationError when the symbol clobbers a framework method " \
|
|
371
|
-
"(review-batch 1 2026-05-14)" do
|
|
372
|
-
# `:params` is defined on ActionController::Base via Metal — declaring
|
|
373
|
-
# `ruact_action :params` would override the request-params accessor.
|
|
374
|
-
expect do
|
|
375
|
-
Class.new do
|
|
376
|
-
def self.name = "BadController"
|
|
377
|
-
include Ruact::Controller
|
|
378
|
-
|
|
379
|
-
ruact_action(:params) { |_p| nil }
|
|
380
|
-
end
|
|
381
|
-
end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
it "raises ArgumentError on a zero-arity block (review-batch 1 2026-05-14)" do
|
|
385
|
-
expect do
|
|
386
|
-
Class.new do
|
|
387
|
-
def self.name = "ExampleController"
|
|
388
|
-
include Ruact::Controller
|
|
389
|
-
|
|
390
|
-
ruact_action(:no_args) { "pong" }
|
|
391
|
-
end
|
|
392
|
-
end.to raise_error(ArgumentError, /must accept exactly one positional parameter/)
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
it "rejects a block with required keyword arguments (re-run-4 #4 — " \
|
|
396
|
-
"block.parameters guard catches `do |p, required:|`)" do
|
|
397
|
-
expect do
|
|
398
|
-
Class.new do
|
|
399
|
-
def self.name = "ExampleController"
|
|
400
|
-
include Ruact::Controller
|
|
401
|
-
|
|
402
|
-
ruact_action(:bad_kwargs) { |_params, required:| required }
|
|
403
|
-
end
|
|
404
|
-
end.to raise_error(ArgumentError, /no required keyword arguments/)
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
it "accepts optional keyword args alongside the positional (re-run-4 #4 — " \
|
|
408
|
-
"`do |params, opt: nil|` is fine)" do
|
|
409
|
-
expect do
|
|
410
|
-
Class.new do
|
|
411
|
-
def self.name = "ExampleController"
|
|
412
|
-
include Ruact::Controller
|
|
413
|
-
|
|
414
|
-
ruact_action(:opt_kwargs) { |_params, opt: nil| opt }
|
|
415
|
-
end
|
|
416
|
-
end.not_to raise_error
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
it "accepts a splat-arity block (do |*args|) since it tolerates one positional arg" do
|
|
420
|
-
expect do
|
|
421
|
-
Class.new do
|
|
422
|
-
def self.name = "ExampleController"
|
|
423
|
-
include Ruact::Controller
|
|
424
|
-
|
|
425
|
-
ruact_action(:splat_args) { |*args| args.first }
|
|
426
|
-
end
|
|
427
|
-
end.not_to raise_error
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
it "rejects a String argument (re-run-2 #4 — String key would 404 on dispatch)" do
|
|
431
|
-
expect do
|
|
432
|
-
Class.new do
|
|
433
|
-
def self.name = "BadController"
|
|
434
|
-
include Ruact::Controller
|
|
435
|
-
|
|
436
|
-
ruact_action("create_post") { |_p| nil }
|
|
437
|
-
end
|
|
438
|
-
end.to raise_error(ArgumentError, /requires a Symbol/)
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
it "rejects clobber of a method already defined on the host class itself " \
|
|
442
|
-
"(re-run-2 #3 — guard extended from framework methods to host methods)" do
|
|
443
|
-
expect do
|
|
444
|
-
Class.new do
|
|
445
|
-
def self.name = "BadController"
|
|
446
|
-
include Ruact::Controller
|
|
447
|
-
|
|
448
|
-
def index; end
|
|
449
|
-
ruact_action(:index) { |_p| nil }
|
|
450
|
-
end
|
|
451
|
-
end.to raise_error(Ruact::ConfigurationError, /would clobber an existing method/)
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
it "rejects clobber of an INHERITED app helper like :current_user " \
|
|
455
|
-
"(re-run-3 #2 — guard extended from own-class to inherited app methods)" do
|
|
456
|
-
# Stand-in for `ApplicationController` defining `current_user` and
|
|
457
|
-
# subclasses inheriting it; declaring `ruact_action :current_user`
|
|
458
|
-
# would silently override the auth helper.
|
|
459
|
-
app_controller = Class.new(ActionController::Base) do
|
|
460
|
-
def current_user; end
|
|
461
|
-
def authenticate_user!; end
|
|
462
|
-
end
|
|
463
|
-
expect do
|
|
464
|
-
Class.new(app_controller) do
|
|
465
|
-
def self.name = "BadController"
|
|
466
|
-
include Ruact::Controller
|
|
467
|
-
|
|
468
|
-
ruact_action(:current_user) { |_p| nil }
|
|
469
|
-
end
|
|
470
|
-
end.to raise_error(Ruact::ConfigurationError, /would clobber an inherited helper/)
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
it "rejects clobber of CSRF callback :verify_authenticity_token " \
|
|
474
|
-
"(re-run-5 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
|
|
475
|
-
expect do
|
|
476
|
-
Class.new do
|
|
477
|
-
def self.name = "BadController"
|
|
478
|
-
include Ruact::Controller
|
|
479
|
-
|
|
480
|
-
ruact_action(:verify_authenticity_token) { |_p| nil }
|
|
481
|
-
end
|
|
482
|
-
end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
it "rejects a block with MULTIPLE required positional parameters " \
|
|
486
|
-
"(re-run-5 #3 — `do |a, b|` silently received nil for `b`)" do
|
|
487
|
-
expect do
|
|
488
|
-
Class.new do
|
|
489
|
-
def self.name = "ExampleController"
|
|
490
|
-
include Ruact::Controller
|
|
491
|
-
|
|
492
|
-
ruact_action(:two_positional) { |_a, _b| nil }
|
|
493
|
-
end
|
|
494
|
-
end.to raise_error(ArgumentError, /must accept exactly one positional/)
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
it "rejects clobber of Kernel#send / Kernel#public_send " \
|
|
498
|
-
"(re-run-3 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
|
|
499
|
-
expect do
|
|
500
|
-
Class.new do
|
|
501
|
-
def self.name = "BadController"
|
|
502
|
-
include Ruact::Controller
|
|
503
|
-
|
|
504
|
-
ruact_action(:send) { |_p| nil }
|
|
505
|
-
end
|
|
506
|
-
end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
it "rejects clobber of an INHERITED ActionController::Base method like :status " \
|
|
510
|
-
"(re-run-6 #2 — denylist widened from hardcoded set to all framework methods - Object methods)" do
|
|
511
|
-
expect do
|
|
512
|
-
Class.new do
|
|
513
|
-
def self.name = "BadController"
|
|
514
|
-
|
|
515
|
-
include Ruact::Controller
|
|
304
|
+
# Story 14.2 (FR104) — the controller's HTML shell delegates its JS asset
|
|
305
|
+
# markup (entry tags + __FLIGHT_DATA) to the single
|
|
306
|
+
# Ruact::ViewHelper#ruact_js_assets implementation. No duplicated tag logic;
|
|
307
|
+
# controller output == a view helper's output (parity guards against drift).
|
|
308
|
+
describe "ruact_js_assets delegation + parity", :story_14_2 do
|
|
309
|
+
let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
|
|
516
310
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
311
|
+
it "delegates the JS block to ruact_js_assets (single implementation)" do
|
|
312
|
+
allow(controller).to receive(:ruact_js_assets).with(payload).and_return("<!--RUACT-JS-->".html_safe)
|
|
313
|
+
html = controller.send(:ruact_html_shell, payload)
|
|
314
|
+
expect(html).to include("<!--RUACT-JS-->")
|
|
315
|
+
expect(controller).to have_received(:ruact_js_assets).with(payload)
|
|
521
316
|
end
|
|
522
317
|
|
|
523
|
-
it "
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
# sentinel guard and run the user's later body. Detect at class-load
|
|
528
|
-
# time via method_added hook so the error is loud at boot.
|
|
529
|
-
expect do
|
|
530
|
-
Class.new do
|
|
531
|
-
def self.name = "BadController"
|
|
532
|
-
|
|
533
|
-
include Ruact::Controller
|
|
318
|
+
it "emits markup byte-identical to a standalone view helper (no drift)" do
|
|
319
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
|
320
|
+
manifest_entry = { "file" => "bootstrap-xyz789.js" }
|
|
321
|
+
allow(controller).to receive(:vite_manifest_entry).and_return(manifest_entry)
|
|
534
322
|
|
|
535
|
-
|
|
323
|
+
view = Object.new
|
|
324
|
+
view.extend(Ruact::ViewHelper)
|
|
325
|
+
allow(view).to receive(:vite_manifest_entry).and_return(manifest_entry)
|
|
536
326
|
|
|
537
|
-
|
|
538
|
-
"from later def"
|
|
539
|
-
end
|
|
540
|
-
end
|
|
541
|
-
end.to raise_error(Ruact::ConfigurationError,
|
|
542
|
-
/registered by `ruact_action :create_post` and then re-defined/)
|
|
327
|
+
expect(controller.send(:ruact_js_assets, payload)).to eq(view.ruact_js_assets(payload))
|
|
543
328
|
end
|
|
544
329
|
|
|
545
|
-
it "
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
#
|
|
549
|
-
|
|
550
|
-
# of chaining through it, those concerns would stop firing as soon
|
|
551
|
-
# as the first `ruact_action` runs.
|
|
552
|
-
recorded = []
|
|
553
|
-
klass = Class.new do
|
|
554
|
-
def self.name = "ExampleController"
|
|
555
|
-
|
|
556
|
-
singleton_class.define_method(:method_added) do |meth|
|
|
557
|
-
recorded << meth
|
|
558
|
-
super(meth)
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
include Ruact::Controller
|
|
562
|
-
|
|
563
|
-
ruact_action(:create_post) { |_p| "from macro" }
|
|
564
|
-
|
|
565
|
-
def helper_method
|
|
566
|
-
:ok
|
|
567
|
-
end
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Both the macro-defined :create_post AND the later `def helper_method`
|
|
571
|
-
# should have been observed by the host's `method_added`.
|
|
572
|
-
expect(recorded).to include(:create_post, :helper_method)
|
|
573
|
-
# The action method is still registered and dispatch-guarded.
|
|
574
|
-
expect(klass.allocate.respond_to?(:create_post)).to be(true)
|
|
575
|
-
end
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
describe "ruact_action cross-controller collisions (Story 8.1 — re-run-3)" do
|
|
579
|
-
before { Ruact.action_registry.clear! }
|
|
580
|
-
|
|
581
|
-
it "raises Ruact::ConfigurationError when the SAME symbol is declared on TWO controllers " \
|
|
582
|
-
"(re-run-3 #1 — silent overwrite would route to whichever loaded last)" do
|
|
583
|
-
Class.new do
|
|
584
|
-
def self.name = "PostsController"
|
|
585
|
-
include Ruact::Controller
|
|
586
|
-
|
|
587
|
-
ruact_action(:create_post) { |_p| nil }
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
expect do
|
|
591
|
-
Class.new do
|
|
592
|
-
def self.name = "AdminPostsController"
|
|
593
|
-
include Ruact::Controller
|
|
594
|
-
|
|
595
|
-
ruact_action(:create_post) { |_p| nil }
|
|
596
|
-
end
|
|
597
|
-
end.to raise_error(Ruact::ConfigurationError, /declared in BOTH/)
|
|
330
|
+
it "exposes ruact_js_assets as PRIVATE on the controller (never a routable action)", :aggregate_failures do
|
|
331
|
+
expect(controller.class.public_method_defined?(:ruact_js_assets)).to be false
|
|
332
|
+
expect(controller.class.private_method_defined?(:ruact_js_assets)).to be true
|
|
333
|
+
# __ruact_component__ is likewise demoted so it is not exposed as an action.
|
|
334
|
+
expect(controller.class.public_method_defined?(:__ruact_component__)).to be false
|
|
598
335
|
end
|
|
599
336
|
end
|
|
600
337
|
end
|
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -276,6 +276,207 @@ RSpec.describe Ruact::Doctor do
|
|
|
276
276
|
end
|
|
277
277
|
end
|
|
278
278
|
|
|
279
|
+
# --- check_serialize_only (Story 13.1, AC2 + AC4) ---
|
|
280
|
+
|
|
281
|
+
describe "#check_serialize_only", :story_13_1 do
|
|
282
|
+
subject(:doctor) { described_class.new(serialize_only_root: scan_root.to_s) }
|
|
283
|
+
|
|
284
|
+
# Injectable scan root → point the tripwire at a fixture tree.
|
|
285
|
+
let(:scan_root) { Pathname.new(Dir.mktmpdir) }
|
|
286
|
+
|
|
287
|
+
after { FileUtils.rm_rf(scan_root) }
|
|
288
|
+
|
|
289
|
+
def write_source(name, content)
|
|
290
|
+
path = scan_root.join(name)
|
|
291
|
+
FileUtils.mkdir_p(path.dirname)
|
|
292
|
+
File.write(path, content)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
context "with a clean tree (no inbound deserializer)" do
|
|
296
|
+
before { write_source("clean.rb", "class Foo\n def bar = 42\nend\n") }
|
|
297
|
+
|
|
298
|
+
it "returns :pass silently" do
|
|
299
|
+
status, msg = doctor.send(:check_serialize_only)
|
|
300
|
+
expect(status).to eq(:pass)
|
|
301
|
+
expect(msg).to include("Serialize-only invariant holds")
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
context "with an unguarded inbound Flight deserializer" do
|
|
306
|
+
before do
|
|
307
|
+
write_source("evil.rb", "class FlightDeserializer\n def call(body)\n parse_flight(body)\n end\nend\n")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "returns :fail" do
|
|
311
|
+
status, = doctor.send(:check_serialize_only)
|
|
312
|
+
expect(status).to eq(:fail)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it "names the offending file:line and points to the ADR invariant" do
|
|
316
|
+
_, msg = doctor.send(:check_serialize_only)
|
|
317
|
+
expect(msg).to include("evil.rb:1") # first offense = the *Deserializer constant
|
|
318
|
+
expect(msg).to include("CVE-2025-55182")
|
|
319
|
+
expect(msg).to include("server-functions-api.md")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
context "with a deserializer carrying the allow annotation" do
|
|
324
|
+
before do
|
|
325
|
+
annotation = ["# ruact:allow", "flight", "deserialization"].join("-")
|
|
326
|
+
write_source("guarded.rb", "def parse_flight(body) #{annotation} reviewed legacy bridge\n body\nend\n")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it "returns :pass (the escape hatch makes it a guard, not a ban)" do
|
|
330
|
+
status, = doctor.send(:check_serialize_only)
|
|
331
|
+
expect(status).to eq(:pass)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
context "with a signal that lives in an excluded location" do
|
|
336
|
+
before do
|
|
337
|
+
# generators' client-side templates are out of scope (browser RSC)
|
|
338
|
+
write_source("lib/generators/ruact/install/templates/app.rb", "def parse_flight(b) = b\n")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "returns :pass (templates are excluded from the scan)" do
|
|
342
|
+
status, = doctor.send(:check_serialize_only)
|
|
343
|
+
expect(status).to eq(:pass)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
context "with a `decode_flight` inbound entry point (review finding R3)" do
|
|
348
|
+
before { write_source("decoder.rb", "def decode_flight(body)\n body\nend\n") }
|
|
349
|
+
|
|
350
|
+
it "returns :fail (decode_flight is an inbound deserialization signal)" do
|
|
351
|
+
status, = doctor.send(:check_serialize_only)
|
|
352
|
+
expect(status).to eq(:fail)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
context "with a deserializer in a differently-located file ALSO named doctor.rb (review finding R1)" do
|
|
357
|
+
# Only the gem's own lib/ruact/doctor.rb is excluded (by exact path), not
|
|
358
|
+
# every basename `doctor.rb` — a nested namesake must still be scanned.
|
|
359
|
+
before { write_source("ruact/flight/doctor.rb", "class FlightDeserializer; end\n") }
|
|
360
|
+
|
|
361
|
+
it "returns :fail (basename collision is not a free pass)" do
|
|
362
|
+
status, = doctor.send(:check_serialize_only)
|
|
363
|
+
expect(status).to eq(:fail)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# --- check_flight_middleware (Story 13.1, AC3 + AC4) ---
|
|
369
|
+
|
|
370
|
+
describe "#check_flight_middleware", :story_13_1 do
|
|
371
|
+
subject(:doctor) { described_class.new }
|
|
372
|
+
|
|
373
|
+
# Plain value objects (not doubles) modelling the iterable middleware stack
|
|
374
|
+
# the check reads — each entry exposes a `.name`, mirroring a real
|
|
375
|
+
# ActionDispatch::MiddlewareStack::Middleware.
|
|
376
|
+
def middleware_entry(name)
|
|
377
|
+
Struct.new(:name).new(name)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def stub_app(stack)
|
|
381
|
+
app = Struct.new(:middleware).new(stack)
|
|
382
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
context "when a response-transforming middleware (Rack::Deflater) is mounted" do
|
|
386
|
+
before { stub_app([middleware_entry("Rack::Deflater")]) }
|
|
387
|
+
|
|
388
|
+
it "returns :warn (never :fail)" do
|
|
389
|
+
status, msg = doctor.send(:check_flight_middleware)
|
|
390
|
+
expect(status).to eq(:warn)
|
|
391
|
+
expect(msg).to include("Rack::Deflater").and include("text/x-component")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
context "when no response-transforming middleware is mounted" do
|
|
396
|
+
before { stub_app([middleware_entry("Rack::Runtime")]) }
|
|
397
|
+
|
|
398
|
+
it "returns :pass" do
|
|
399
|
+
status, = doctor.send(:check_flight_middleware)
|
|
400
|
+
expect(status).to eq(:pass)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
context "when no Rails application is present" do
|
|
405
|
+
before { allow(Rails).to receive(:application).and_return(nil) }
|
|
406
|
+
|
|
407
|
+
it "returns :pass (guarded edge context)" do
|
|
408
|
+
status, = doctor.send(:check_flight_middleware)
|
|
409
|
+
expect(status).to eq(:pass)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
context "when the app is not yet booted (middleware is a non-enumerable proxy)" do
|
|
414
|
+
# Mirrors a pre-`initialize!` Rails::Configuration::MiddlewareStackProxy,
|
|
415
|
+
# which does NOT respond to :each — must be skipped, not crash on filter_map.
|
|
416
|
+
before do
|
|
417
|
+
proxy = Object.new # responds to neither :each nor :filter_map
|
|
418
|
+
app = Struct.new(:middleware).new(proxy)
|
|
419
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "returns :pass without raising" do
|
|
423
|
+
expect { doctor.send(:check_flight_middleware) }.not_to raise_error
|
|
424
|
+
status, = doctor.send(:check_flight_middleware)
|
|
425
|
+
expect(status).to eq(:pass)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# --- :warn status semantics (Story 13.1, AC3) ---
|
|
431
|
+
|
|
432
|
+
describe "#format_result with :warn", :story_13_1 do
|
|
433
|
+
subject(:doctor) { described_class.new }
|
|
434
|
+
|
|
435
|
+
it "renders :warn with the ⚠ glyph (not ✗)" do
|
|
436
|
+
expect(doctor.send(:format_result, :warn, "heads up")).to eq("⚠ heads up")
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
describe "#run with an unexpected status (review finding R1)", :story_13_1 do
|
|
441
|
+
before do
|
|
442
|
+
make_manifest
|
|
443
|
+
make_controller(with_include: true)
|
|
444
|
+
make_layout(with_sentinel: true)
|
|
445
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it "fails the run when a check returns a status that is neither :pass nor :warn" do
|
|
449
|
+
doctor = described_class.new
|
|
450
|
+
# All other checks pass; a malformed status (rendered ✗) must NOT be
|
|
451
|
+
# silently treated as a pass — only :pass / :warn are success.
|
|
452
|
+
allow(doctor).to receive(:check_streaming).and_return([:error, "broken status"])
|
|
453
|
+
expect(doctor.run).to be false
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
describe ".run with a :warn present (Story 13.1, AC3)", :story_13_1 do
|
|
458
|
+
before do
|
|
459
|
+
make_manifest
|
|
460
|
+
make_controller(with_include: true)
|
|
461
|
+
make_layout(with_sentinel: true)
|
|
462
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
463
|
+
app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
|
|
464
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
it "still returns true — a :warn does not fail the run" do
|
|
468
|
+
expect(described_class.run).to be true
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
it "prints the warning glyph" do
|
|
472
|
+
expect { described_class.run }.to output(/⚠.*Rack::Deflater/).to_stdout
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it "does not print the fix hint" do
|
|
476
|
+
expect { described_class.run }.not_to output(/rails generate/).to_stdout
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
279
480
|
# --- run / .run ---
|
|
280
481
|
|
|
281
482
|
describe ".run / #run (AC#1, #7)" do
|
|
@@ -18,7 +18,10 @@ module Ruact
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
let(:handler) { handler_class.new }
|
|
21
|
-
|
|
21
|
+
# Story 13.5 — the hook now forwards +template.identifier+ into the
|
|
22
|
+
# preprocessor (for contract-violation file:line), so the stand-in template
|
|
23
|
+
# must answer +identifier+.
|
|
24
|
+
let(:fake_template) { Struct.new(:identifier).new("app/views/posts/show.html.erb") }
|
|
22
25
|
|
|
23
26
|
describe "#call" do
|
|
24
27
|
it "applies ErbPreprocessor.transform to source before calling super" do
|