ruact 0.0.5 → 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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -123,6 +123,42 @@ module Ruact
123
123
  end
124
124
  end
125
125
 
126
+ describe "#contract_for (Story 13.5)", :story_13_5 do
127
+ let(:contract) { { "props" => { "postId" => "required" } } }
128
+
129
+ it "returns the optional contract Hash when present" do
130
+ manifest = described_class.from_hash(
131
+ "LikeButton" => manifest_data["LikeButton"].merge("contract" => contract)
132
+ )
133
+ expect(manifest.contract_for("LikeButton")).to eq(contract)
134
+ end
135
+
136
+ it "returns nil when the component declares no contract (back-compat)" do
137
+ manifest = described_class.from_hash(manifest_data)
138
+ expect(manifest.contract_for("LikeButton")).to be_nil
139
+ end
140
+
141
+ it "returns nil for an unknown component (never raises — fail open)" do
142
+ manifest = described_class.from_hash(manifest_data)
143
+ expect(manifest.contract_for("Nope")).to be_nil
144
+ end
145
+
146
+ it "honors the co-located/shared resolve_key precedence" do
147
+ data = dual_manifest_data.dup
148
+ data["posts/_like_button"] = data["posts/_like_button"].merge("contract" => contract)
149
+ manifest = described_class.from_hash(data)
150
+ expect(manifest.contract_for("LikeButton", controller_path: "posts")).to eq(contract)
151
+ expect(manifest.contract_for("LikeButton")).to be_nil
152
+ end
153
+
154
+ it "reads cleanly from a frozen manifest" do
155
+ manifest = described_class.from_hash(
156
+ "LikeButton" => manifest_data["LikeButton"].merge("contract" => contract)
157
+ ).freeze
158
+ expect(manifest.contract_for("LikeButton")).to eq(contract)
159
+ end
160
+ end
161
+
126
162
  describe "#reference_for closest-match suggestion (Story 7.4)" do
127
163
  let(:shared_only_manifest) { described_class.from_hash(manifest_data) }
128
164
  let(:dual_manifest) { described_class.from_hash(dual_manifest_data) }
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe ComponentContract, :story_13_5 do
7
+ def validate(prop_names, contract)
8
+ described_class.validate(
9
+ component_name: "LikeButton", prop_names: prop_names, contract: contract,
10
+ at: { file: "app/views/posts/show.html.erb", line: 7, snippet: "<LikeButton ... />" }
11
+ )
12
+ end
13
+
14
+ describe "fail open" do
15
+ it "is a no-op when the contract is nil (opt-in)" do
16
+ expect { validate(%w[anything goes], nil) }.not_to raise_error
17
+ end
18
+ end
19
+
20
+ describe "missing required prop (AC#1, AC#3)" do
21
+ let(:contract) { { "props" => { "postId" => "required", "size" => "optional" } } }
22
+
23
+ it "raises ComponentContractError naming the component, file:line, prop, and a fix" do
24
+ expect { validate(["size"], contract) }.to raise_error(ComponentContractError) do |e|
25
+ expect(e.message).to include("LikeButton")
26
+ expect(e.message).to include("app/views/posts/show.html.erb:7")
27
+ expect(e.message).to include("postId")
28
+ expect(e.message).to include("add the required prop")
29
+ end
30
+ end
31
+
32
+ it "is a PreprocessorError (NFR30 overlay lineage)" do
33
+ expect { validate([], contract) }.to raise_error(PreprocessorError)
34
+ end
35
+
36
+ it "passes when the required prop is present" do
37
+ expect { validate(%w[postId size], contract) }.not_to raise_error
38
+ end
39
+ end
40
+
41
+ describe "unknown prop with did-you-mean (AC#1, AC#3)" do
42
+ let(:contract) { { "props" => { "postId" => "required", "initialCount" => "optional" } } }
43
+
44
+ it "raises with a Damerau-Levenshtein closest-match suggestion for a near typo" do
45
+ expect { validate(%w[postId postID], contract) }.to raise_error(ComponentContractError) do |e|
46
+ expect(e.message).to include("unknown prop")
47
+ expect(e.message).to include("postID")
48
+ expect(e.message).to include('did you mean "postId"?')
49
+ end
50
+ end
51
+
52
+ it "raises without a suggestion when nothing is close" do
53
+ expect { validate(%w[postId zzzzzzzz], contract) }.to raise_error(ComponentContractError) do |e|
54
+ expect(e.message).to include("zzzzzzzz")
55
+ expect(e.message).not_to include("did you mean")
56
+ end
57
+ end
58
+
59
+ it "allows undeclared props when passthrough is true" do
60
+ open = contract.merge("passthrough" => true)
61
+ expect { validate(%w[postId whatever], open) }.not_to raise_error
62
+ end
63
+
64
+ # Codex review (Patch 1) — a typo OF a required prop (the required name is
65
+ # therefore "missing") must surface the did-you-mean, not the less-helpful
66
+ # "missing required" message. FR100's canonical postID/postId case.
67
+ it "prefers the did-you-mean when the only prop is a typo of a missing required prop" do
68
+ expect { validate(%w[postID], contract) }.to raise_error(ComponentContractError) do |e|
69
+ expect(e.message).to include("unknown prop")
70
+ expect(e.message).to include('did you mean "postId"?')
71
+ expect(e.message).not_to include("missing required")
72
+ end
73
+ end
74
+
75
+ it "still reports the missing required prop when the unknown prop is NOT a near typo" do
76
+ expect { validate(%w[zzzzzzzz], contract) }.to raise_error(ComponentContractError) do |e|
77
+ expect(e.message).to include("missing required prop")
78
+ expect(e.message).to include("postId")
79
+ end
80
+ end
81
+ end
82
+
83
+ describe "slots (AC#5)" do
84
+ let(:contract) do
85
+ { "props" => { "title" => "required" }, "slots" => { "header" => "required", "footer" => "optional" } }
86
+ end
87
+
88
+ it "raises when a required slot is omitted" do
89
+ expect { validate(%w[title], contract) }.to raise_error(ComponentContractError) do |e|
90
+ expect(e.message).to include("missing required slot")
91
+ expect(e.message).to include("header")
92
+ end
93
+ end
94
+
95
+ it "treats a declared slot name as a valid attribute (not unknown)" do
96
+ expect { validate(%w[title header], contract) }.not_to raise_error
97
+ end
98
+
99
+ it "accepts the array form (all optional) and is a no-op when slots are absent" do
100
+ arr = { "props" => { "title" => "required" }, "slots" => %w[header footer] }
101
+ expect { validate(%w[title], arr) }.not_to raise_error
102
+ end
103
+
104
+ it "does nothing when the contract declares no slots" do
105
+ expect { validate(%w[title], { "props" => { "title" => "required" } }) }.not_to raise_error
106
+ end
107
+ end
108
+
109
+ describe "location formatting" do
110
+ it "degrades gracefully when file/line are absent" do
111
+ expect do
112
+ described_class.validate(
113
+ component_name: "X", prop_names: [], contract: { "props" => { "a" => "required" } }
114
+ )
115
+ end.to raise_error(ComponentContractError, /unknown location/)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -267,7 +267,6 @@ module Ruact
267
267
  expect(config.strict_serialization).to be(false)
268
268
  expect(config.suspense_timeout).to eq(5.0)
269
269
  expect(config.vite_dev_server).to eq("http://localhost:5173")
270
- expect(config.current_user_resolver).to be_nil
271
270
  end
272
271
 
273
272
  it "rejects post-boot mutation against the default-frozen instance (AC5)" do
@@ -276,39 +275,6 @@ module Ruact
276
275
  end
277
276
  end
278
277
 
279
- describe "Story 8.3 — current_user_resolver attribute", :story_8_3 do
280
- it "defaults to nil so apps without standalone actions never get a phantom resolver" do
281
- expect(Ruact.config.current_user_resolver).to be_nil
282
- end
283
-
284
- it "accepts a lambda inside Ruact.configure and exposes it after publication" do
285
- resolver = ->(env) { env["warden"]&.user }
286
- Ruact.configure { |c| c.current_user_resolver = resolver }
287
- expect(Ruact.config.current_user_resolver).to be(resolver)
288
- end
289
-
290
- it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
291
- Ruact.configure { |c| c.current_user_resolver = ->(_env) {} }
292
- expect { Ruact.config.current_user_resolver = ->(_env) { "other" } }
293
- .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#current_user_resolver/)
294
- end
295
-
296
- it "is carried across atomic re-configuration (template clone)" do
297
- first = ->(_env) { :first }
298
- Ruact.configure { |c| c.current_user_resolver = first }
299
- Ruact.configure { |c| c.suspense_timeout = 6.0 } # untouched
300
- expect(Ruact.config.current_user_resolver).to be(first)
301
- end
302
-
303
- it "deep-freezes the resolver lambda at publication time (Story 8.3 review R6) — " \
304
- "identity is preserved AND `frozen?` reports true" do
305
- resolver = ->(_env) {}
306
- Ruact.configure { |c| c.current_user_resolver = resolver }
307
- expect(Ruact.config.current_user_resolver).to be(resolver)
308
- expect(Ruact.config.current_user_resolver).to be_frozen
309
- end
310
- end
311
-
312
278
  describe "Story 8.5 — max_upload_bytes attribute", :story_8_5 do
313
279
  it "defaults to 10 MB (10 * 1024 * 1024 bytes)" do
314
280
  expect(Ruact.config.max_upload_bytes).to eq(10 * 1024 * 1024)
@@ -482,6 +448,57 @@ module Ruact
482
448
  end
483
449
  end
484
450
 
451
+ describe "Story 10.5 — shadcn_compatible_versions attribute", :story_10_5 do
452
+ it "defaults to [1, 2]" do
453
+ expect(Ruact.config.shadcn_compatible_versions).to eq([1, 2])
454
+ end
455
+
456
+ it "accepts a custom list inside Ruact.configure" do
457
+ Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
458
+ expect(Ruact.config.shadcn_compatible_versions).to eq([1, 2, 3])
459
+ end
460
+
461
+ it "is sealed by the standard freeze contract — direct mutation raises ConfigurationError" do
462
+ Ruact.configure { |c| c.shadcn_compatible_versions = [2] }
463
+ expect { Ruact.config.shadcn_compatible_versions = [3] }
464
+ .to raise_error(Ruact::ConfigurationError, /Ruact::Configuration#shadcn_compatible_versions/)
465
+ end
466
+
467
+ it "deep-freezes the Array value so in-place mutation cannot bypass the writer guard" do
468
+ Ruact.configure { |c| c.shadcn_compatible_versions = [2] }
469
+ expect(Ruact.config.shadcn_compatible_versions).to be_frozen
470
+ expect { Ruact.config.shadcn_compatible_versions << 3 }.to raise_error(FrozenError)
471
+ end
472
+
473
+ it "is carried across atomic re-configuration (template clone)" do
474
+ Ruact.configure { |c| c.shadcn_compatible_versions = [2, 3] }
475
+ Ruact.configure { |c| c.suspense_timeout = 6.0 }
476
+ expect(Ruact.config.shadcn_compatible_versions).to eq([2, 3])
477
+ end
478
+
479
+ describe "writer-time validation" do
480
+ it "rejects a non-Array with ConfigurationError naming the offending value + class" do
481
+ expect { Ruact.configure { |c| c.shadcn_compatible_versions = 2 } }
482
+ .to raise_error(Ruact::ConfigurationError, /got 2 \(Integer\)/)
483
+ end
484
+
485
+ it "rejects an empty Array" do
486
+ expect { Ruact.configure { |c| c.shadcn_compatible_versions = [] } }
487
+ .to raise_error(Ruact::ConfigurationError, /non-empty Array/)
488
+ end
489
+
490
+ it "rejects an Array with non-Integer elements" do
491
+ expect { Ruact.configure { |c| c.shadcn_compatible_versions = ["2"] } }
492
+ .to raise_error(Ruact::ConfigurationError, /only Integer/)
493
+ end
494
+
495
+ it "rejects nil" do
496
+ expect { Ruact.configure { |c| c.shadcn_compatible_versions = nil } }
497
+ .to raise_error(Ruact::ConfigurationError, /non-empty Array/)
498
+ end
499
+ end
500
+ end
501
+
485
502
  describe "error message includes caller location" do
486
503
  it "names the file:line of the offending mutation (AC2.2)" do
487
504
  Ruact.configure { |c| c.suspense_timeout = 5.0 }
@@ -19,6 +19,8 @@ require "spec_helper"
19
19
  require "rack/test"
20
20
  require "tmpdir"
21
21
  require "fileutils"
22
+ require "pathname"
23
+ require "active_model"
22
24
 
23
25
  # Ruact::Controller is normally loaded by the Railtie's `ruact.load_controller`
24
26
  # initializer at app boot. This spec instantiates a Rails::Application but does
@@ -36,6 +38,27 @@ module ControllerRequestSpecSupport
36
38
  @app_class ||= build_app_class
37
39
  end
38
40
 
41
+ # Story 10.0 — a writable on-disk app root so an implicit-`default_render`
42
+ # controller's conventional template (`Rails.root/app/views/<ctrl>/<action>`)
43
+ # exists. Unlike the `append_view_path` demo controllers (which call
44
+ # `ruact_render` directly), the implicit path is what `ruact_template_exists?`
45
+ # probes — the bug under test lives in `default_render`, never reached when a
46
+ # template is only on an appended path. Memoized so `config.root` and every
47
+ # `write_view` call share one directory.
48
+ def app_root
49
+ @app_root ||= Pathname.new(Dir.mktmpdir("ruact-story-10-0-root"))
50
+ end
51
+
52
+ # Story 10.0 — write a conventional template under the app root so Rails'
53
+ # default view path picks it up and `ruact_template_exists?` returns true.
54
+ # Called at file-load time (before `initialize!`) so the `app/views` dir
55
+ # exists when Rails computes `paths["app/views"].existent`.
56
+ def write_view(controller_path, action, erb)
57
+ view_dir = File.join(app_root, "app", "views", controller_path)
58
+ FileUtils.mkdir_p(view_dir)
59
+ File.write(File.join(view_dir, "#{action}.html.erb"), erb)
60
+ end
61
+
39
62
  def boot!
40
63
  return if @booted
41
64
 
@@ -68,7 +91,11 @@ module ControllerRequestSpecSupport
68
91
  private
69
92
 
70
93
  def build_app_class
94
+ app_root_path = app_root
71
95
  Class.new(Rails::Application) do
96
+ # Story 10.0 — a real on-disk root so the implicit-render controller's
97
+ # conventional `app/views` template is discoverable by `default_render`.
98
+ config.root = app_root_path
72
99
  config.eager_load = false
73
100
  config.consider_all_requests_local = true
74
101
  config.action_controller.perform_caching = false
@@ -80,11 +107,60 @@ module ControllerRequestSpecSupport
80
107
 
81
108
  routes.append do
82
109
  get "/demo/show", to: "controller_request_spec_support/demo#show"
110
+ # Story 10.0 — implicit-`default_render` page action (empty body), backed
111
+ # by a conventional `Rails.root/app/views` template, for the non-HTML
112
+ # Accept graceful-degradation matrix.
113
+ get "/implicit-demo/show", to: "controller_request_spec_support/implicit_demo#show"
114
+ # Story 13.3 (FR98) — Bucket-1 redirect-back round-trip routes.
115
+ get "/errors-demo/new", to: "controller_request_spec_support/errors_demo#new"
116
+ post "/errors-demo/create", to: "controller_request_spec_support/errors_demo#create"
117
+ post "/errors-demo/create_valid", to: "controller_request_spec_support/errors_demo#create_valid"
83
118
  end
84
119
  end
85
120
  end
86
121
  end
87
122
 
123
+ # Story 13.3 (FR98) — an ActiveModel record with a presence validation for the
124
+ # redirect-back round-trip demo controller below.
125
+ class ErrorsDemoPost
126
+ include ActiveModel::Model
127
+ include ActiveModel::Attributes
128
+
129
+ attribute :title, :string
130
+
131
+ validates :title, presence: true
132
+
133
+ def self.name = "ErrorsDemoPost"
134
+ end
135
+
136
+ # Story 13.3 (FR98) — the Bucket-1 (native form / navigation) Inertia-style
137
+ # redirect-back demo: `create` registers errors via `ruact_errors` then
138
+ # `redirect_to`s the form; the errors survive the Flight redirect in `flash`
139
+ # and arrive as an `errors` prop on the re-rendered `new` page.
140
+ class ErrorsDemoController < ActionController::Base
141
+ include Ruact::Controller
142
+
143
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
144
+
145
+ def new
146
+ ruact_render
147
+ end
148
+
149
+ def create
150
+ post = ErrorsDemoPost.new(title: nil)
151
+ post.valid? # false → populates errors
152
+ ruact_errors(post)
153
+ redirect_to "/errors-demo/new"
154
+ end
155
+
156
+ def create_valid
157
+ post = ErrorsDemoPost.new(title: "Hi")
158
+ post.valid? # true → no errors
159
+ ruact_errors(post)
160
+ redirect_to "/errors-demo/new"
161
+ end
162
+ end
163
+
88
164
  # Demo controller for the spec. Uses an inline `append_view_path` instead of
89
165
  # relying on Rails.root/app/views, so Controller#ruact_render is invoked
90
166
  # directly from #show rather than via default_render (which short-circuits
@@ -98,8 +174,31 @@ module ControllerRequestSpecSupport
98
174
  ruact_render
99
175
  end
100
176
  end
177
+
178
+ # Story 10.0 — an implicit-`default_render` page controller: an EMPTY GET
179
+ # action (no `render`/`respond_to`), backed by a CONVENTIONAL template at
180
+ # `Rails.root/app/views/.../show.html.erb` (written via `write_view` below).
181
+ # This is the exact scaffold shape (ivar-only GET) whose `*/*` request 500'd
182
+ # pre-10.0. No `append_view_path` — the template must live on the default path
183
+ # that `ruact_template_exists?` probes for the bug to be reachable.
184
+ class ImplicitDemoController < ActionController::Base
185
+ include Ruact::Controller
186
+
187
+ def show; end
188
+ end
101
189
  end
102
190
 
191
+ # Story 10.0 — write the implicit-render template at file-load time (before the
192
+ # app's `initialize!` in `boot!`) so the `app/views` dir exists when Rails
193
+ # computes its view paths.
194
+ ControllerRequestSpecSupport.write_view(
195
+ "controller_request_spec_support/implicit_demo", "show", <<~ERB
196
+ <div>
197
+ <DemoButton label={"hello"} />
198
+ </div>
199
+ ERB
200
+ )
201
+
103
202
  # Reset Rails.application so this spec can boot its own minimal app even if a
104
203
  # prior spec ran a different Rails::Application subclass (the constant is
105
204
  # memoized in Rails::Application; we own the reset here).
@@ -123,6 +222,13 @@ module Ruact # rubocop:disable Style/OneClassPerFile
123
222
  # real logger that survives the example lifecycle.
124
223
  Rails.logger = Logger.new(IO::NULL)
125
224
  ControllerRequestSpecSupport.boot!
225
+ # Story 10.0 — pin Rails.root to the booted app root. The Rails.root stub
226
+ # (spec/support/rails_stub.rb) is a writable singleton ivar; doctor_spec
227
+ # sets `Rails.root = <its tmpdir>` and never clears it, so in full-suite
228
+ # ordering a leftover root would make `ruact_template_exists?` (which
229
+ # probes `Rails.root/app/views`) miss the implicit-render template → super
230
+ # → the very 500 this story closes. Re-pinning per example is order-proof.
231
+ Rails.root = ControllerRequestSpecSupport.app_root
126
232
  # Re-prime Ruact.config after spec_helper's per-example reset wiped it.
127
233
  Ruact.configure do |c|
128
234
  c.manifest_path = ControllerRequestSpecSupport.manifest_path
@@ -200,5 +306,163 @@ module Ruact # rubocop:disable Style/OneClassPerFile
200
306
  expect(controller.instance_variable_defined?(:@ruact_render_context)).to be(false)
201
307
  end
202
308
  end
309
+
310
+ # Story 13.3 (FR98) — the Bucket-1 (native form / navigation) half of the
311
+ # Inertia-style validation `errors` round-trip (AC4): errors survive a
312
+ # redirect-back in flash and arrive as an `errors` prop on the re-render.
313
+ describe "Story 13.3: redirect-back errors prop (FR98)", :story_13_3 do
314
+ let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
315
+
316
+ it "exposes errors={} on a plain page render with no prior redirect (always present)" do
317
+ get "/errors-demo/new", {}, flight_headers
318
+ expect(last_response.status).to eq(200)
319
+ expect(last_response.body).to include("DemoButton")
320
+ # The component receives a present-but-empty errors prop, never a message.
321
+ expect(last_response.body).not_to include("can't be blank")
322
+ end
323
+
324
+ it "survives a redirect-back and re-renders with the errors prop populated" do
325
+ # Writing request (Bucket 1, Flight): registers errors, then redirects.
326
+ post "/errors-demo/create", {}, flight_headers
327
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
328
+ expect(last_response.body).to include("redirectUrl")
329
+
330
+ # The router follows the redirect with a fresh Flight GET; rack-test
331
+ # carries the session cookie, so flash[:ruact_errors] is read back.
332
+ get "/errors-demo/new", {}, flight_headers
333
+ expect(last_response.status).to eq(200)
334
+ expect(last_response.body).to include("Title can't be blank")
335
+ end
336
+
337
+ it "registers errors={} for a successful save and does NOT leak a message across the redirect" do
338
+ post "/errors-demo/create_valid", {}, flight_headers
339
+ get "/errors-demo/new", {}, flight_headers
340
+ expect(last_response.status).to eq(200)
341
+ expect(last_response.body).not_to include("can't be blank")
342
+ end
343
+
344
+ it "is single-use: a second re-render after the flash is swept shows no errors" do
345
+ post "/errors-demo/create", {}, flight_headers
346
+ get "/errors-demo/new", {}, flight_headers
347
+ expect(last_response.body).to include("Title can't be blank")
348
+
349
+ # flash is swept after the first read; the next render is clean.
350
+ get "/errors-demo/new", {}, flight_headers
351
+ expect(last_response.body).not_to include("Title can't be blank")
352
+ end
353
+ end
354
+
355
+ # Story 10.0 — implicit `default_render` must degrade gracefully on non-HTML
356
+ # Accept. Exercised against an ivar-only GET page action (empty body) whose
357
+ # conventional template exists, so the request traverses `default_render`'s
358
+ # activation predicate (NOT a hand-written `ruact_render` like demo#show).
359
+ describe "Story 10.0: default_render graceful degradation on non-HTML Accept", :story_10_0 do
360
+ it "GET with Accept: */* renders the HTML shell (was a 500 pre-10.0) (AC1)" do
361
+ # RED pre-fix: `*/*` → format.html? false, ruact_request? false → super →
362
+ # Rails renders the .html.erb outside a ruact_render flow → raises
363
+ # "__ruact_component__ called outside a ruact_render flow".
364
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "*/*" }
365
+ expect(last_response.status).to(eq(200),
366
+ "expected 200, got #{last_response.status} body=#{last_response.body[0, 400]}")
367
+ expect(last_response.headers["Content-Type"]).to include("text/html")
368
+ expect(last_response.body).to include("DemoButton")
369
+ expect(last_response.body).not_to include("__ruact_component__ called outside a ruact_render flow")
370
+ end
371
+
372
+ it "GET with an empty Accept header (defaults to HTML) renders the HTML shell (AC1)" do
373
+ # An empty Accept token parses to `[nil]` (not blank, not a concrete
374
+ # type) — it must degrade to the HTML shell, never raise NoMethodError.
375
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "" }
376
+ expect(last_response.status).to eq(200)
377
+ expect(last_response.headers["Content-Type"]).to include("text/html")
378
+ expect(last_response.body).to include("DemoButton")
379
+ end
380
+
381
+ it "GET with Accept: text/html renders the same HTML shell as */* (AC2)" do
382
+ # AC2: the explicit text/html path is unchanged and the */* path now
383
+ # yields the SAME shell. Normalize the per-request CSRF token (the only
384
+ # request-varying bytes) before comparing.
385
+ strip_csrf = ->(body) { body.gsub(/content="[^"]+"/, 'content="CSRF"') }
386
+
387
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "text/html" }
388
+ html_response = last_response.body
389
+ expect(last_response.status).to eq(200)
390
+ expect(last_response.headers["Content-Type"]).to include("text/html")
391
+ expect(html_response).to include("DemoButton")
392
+
393
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "*/*" }
394
+ expect(strip_csrf.call(last_response.body)).to eq(strip_csrf.call(html_response))
395
+ end
396
+
397
+ it "GET with Accept: text/x-component returns a raw Flight payload (AC3)" do
398
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
399
+ expect(last_response.status).to eq(200)
400
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
401
+ expect(last_response.body).to match(%r{\h+:I\[.*/DemoButton\.jsx})
402
+ end
403
+
404
+ it "GET with the Ruact-Request: 1 header returns a Flight payload (AC3)" do
405
+ get "/implicit-demo/show", {}, { "HTTP_RUACT_REQUEST" => "1" }
406
+ expect(last_response.status).to eq(200)
407
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
408
+ end
409
+
410
+ it "GET with concrete Accept: application/json bypasses RSC to super (AC4)" do
411
+ # The concrete non-HTML format carries no html/wildcard accept, so
412
+ # `default_render` must fall through to `super` (Rails default rendering).
413
+ # With no JSON template and no respond_to, Rails raises an UnknownFormat /
414
+ # MissingTemplate — NOT the ruact outside-flow 500. We only assert it is
415
+ # NOT the ruact bug; Rails' exact choice of error is its own concern.
416
+ error = nil
417
+ begin
418
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json" }
419
+ rescue StandardError => e
420
+ error = e
421
+ end
422
+ expect(error).not_to be_nil
423
+ expect(error.message).not_to include("__ruact_component__ called outside a ruact_render flow")
424
+ expect(error).to be_a(ActionController::ActionControllerError).or(be_a(ActionView::MissingTemplate))
425
+ end
426
+
427
+ it "GET with two concrete non-HTML types (application/json, application/xml) bypasses to super (AC4)" do
428
+ # AC4's literal example: NEITHER member is html nor the wildcard, so the
429
+ # predicate is false and the request must fall through to `super`.
430
+ error = nil
431
+ begin
432
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, application/xml" }
433
+ rescue StandardError => e
434
+ error = e
435
+ end
436
+ expect(error).not_to be_nil
437
+ expect(error.message).not_to include("__ruact_component__ called outside a ruact_render flow")
438
+ end
439
+
440
+ it "GET with a mixed Accept that includes text/html (application/json, text/html) renders the shell" do
441
+ # HTML is acceptable (listed explicitly) → membership predicate activates
442
+ # the shell. default_render is only reached on an implicitly-rendered
443
+ # action (no JSON representation to prefer), so serving the accepted HTML
444
+ # is the sensible non-error outcome — and a 200, not the pre-10.0 500.
445
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, text/html" }
446
+ expect(last_response.status).to eq(200)
447
+ expect(last_response.headers["Content-Type"]).to include("text/html")
448
+ expect(last_response.body).to include("DemoButton")
449
+ end
450
+
451
+ it "GET with a concrete-then-wildcard Accept (application/json, */*) renders the shell" do
452
+ get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, */*" }
453
+ expect(last_response.status).to eq(200)
454
+ expect(last_response.headers["Content-Type"]).to include("text/html")
455
+ expect(last_response.body).to include("DemoButton")
456
+ end
457
+
458
+ it "GET with a real browser Accept (text/html,...,*/*;q=0.8) renders the shell (AC2-adjacent)" do
459
+ get "/implicit-demo/show", {}, {
460
+ "HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
461
+ }
462
+ expect(last_response.status).to eq(200)
463
+ expect(last_response.headers["Content-Type"]).to include("text/html")
464
+ expect(last_response.body).to include("DemoButton")
465
+ end
466
+ end
203
467
  end
204
468
  end