ruact 0.0.5 → 0.0.7
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 +74 -18
- 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 +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- 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 +109 -0
- data/lib/ruact.rb +20 -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 +70 -328
- 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/manifest_resolver_spec.rb +159 -0
- 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 +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -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 +58 -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
|
@@ -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
|