ruact 0.0.1
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 +7 -0
- data/.github/workflows/ci.yml +166 -0
- data/.rubocop.yml +89 -0
- data/CHANGELOG.md +32 -0
- data/README.md +35 -0
- data/RELEASING.md +203 -0
- data/Rakefile +10 -0
- data/SECURITY.md +62 -0
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- data/lib/generators/ruact/install/install_generator.rb +100 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
- data/lib/ruact/client_manifest.rb +115 -0
- data/lib/ruact/component_registry.rb +31 -0
- data/lib/ruact/configuration.rb +32 -0
- data/lib/ruact/controller.rb +195 -0
- data/lib/ruact/doctor.rb +84 -0
- data/lib/ruact/erb_preprocessor.rb +120 -0
- data/lib/ruact/erb_preprocessor_hook.rb +20 -0
- data/lib/ruact/errors.rb +14 -0
- data/lib/ruact/flight/react_element.rb +40 -0
- data/lib/ruact/flight/renderer.rb +73 -0
- data/lib/ruact/flight/request.rb +54 -0
- data/lib/ruact/flight/row_emitter.rb +37 -0
- data/lib/ruact/flight/serializer.rb +215 -0
- data/lib/ruact/flight.rb +12 -0
- data/lib/ruact/html_converter.rb +159 -0
- data/lib/ruact/railtie.rb +99 -0
- data/lib/ruact/render_pipeline.rb +107 -0
- data/lib/ruact/serializable.rb +58 -0
- data/lib/ruact/version.rb +5 -0
- data/lib/ruact/view_helper.rb +23 -0
- data/lib/ruact.rb +48 -0
- data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
- data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
- data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
- data/lib/rubocop/cop/ruact.rb +5 -0
- data/lib/tasks/benchmark.rake +70 -0
- data/lib/tasks/rsc.rake +9 -0
- data/sig/ruact.rbs +4 -0
- data/spec/benchmarks/baseline.json +1 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
- data/spec/fixtures/flight/README.md +88 -0
- data/spec/fixtures/flight/array.txt +1 -0
- data/spec/fixtures/flight/as_json_object.txt +2 -0
- data/spec/fixtures/flight/boolean_false.txt +1 -0
- data/spec/fixtures/flight/boolean_true.txt +1 -0
- data/spec/fixtures/flight/client_component_with_props.txt +2 -0
- data/spec/fixtures/flight/client_reference.txt +2 -0
- data/spec/fixtures/flight/hash.txt +1 -0
- data/spec/fixtures/flight/nil.txt +1 -0
- data/spec/fixtures/flight/number_float.txt +1 -0
- data/spec/fixtures/flight/number_integer.txt +1 -0
- data/spec/fixtures/flight/react_element_no_props.txt +1 -0
- data/spec/fixtures/flight/redirect_row.txt +1 -0
- data/spec/fixtures/flight/serializable_object.txt +2 -0
- data/spec/fixtures/flight/string_basic.txt +1 -0
- data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
- data/spec/ruact/client_manifest_spec.rb +126 -0
- data/spec/ruact/controller_spec.rb +213 -0
- data/spec/ruact/doctor_spec.rb +234 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
- data/spec/ruact/erb_preprocessor_spec.rb +89 -0
- data/spec/ruact/errors_spec.rb +43 -0
- data/spec/ruact/flight/renderer_spec.rb +122 -0
- data/spec/ruact/flight/serializer_spec.rb +453 -0
- data/spec/ruact/html_converter_spec.rb +147 -0
- data/spec/ruact/install_generator_spec.rb +212 -0
- data/spec/ruact/railtie_spec.rb +156 -0
- data/spec/ruact/render_pipeline_spec.rb +474 -0
- data/spec/ruact/serializable_spec.rb +53 -0
- data/spec/ruact/view_helper_spec.rb +46 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
- data/spec/support/rails_stub.rb +45 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- metadata +136 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
RSpec.describe RenderPipeline do
|
|
7
|
+
let(:manifest) do
|
|
8
|
+
ClientManifest.from_hash({
|
|
9
|
+
"LikeButton" => {
|
|
10
|
+
"id" => "/assets/LikeButton-abc.js",
|
|
11
|
+
"name" => "LikeButton",
|
|
12
|
+
"chunks" => ["/assets/LikeButton-abc.js"]
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
let(:manifest_with_post_card) do
|
|
18
|
+
ClientManifest.from_hash({
|
|
19
|
+
"LikeButton" => {
|
|
20
|
+
"id" => "/assets/LikeButton-abc.js",
|
|
21
|
+
"name" => "LikeButton",
|
|
22
|
+
"chunks" => ["/assets/LikeButton-abc.js"]
|
|
23
|
+
},
|
|
24
|
+
"PostCard" => {
|
|
25
|
+
"id" => "/assets/PostCard-abc.js",
|
|
26
|
+
"name" => "PostCard",
|
|
27
|
+
"chunks" => ["/assets/PostCard-abc.js"]
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:pipeline) { described_class.new(manifest) }
|
|
33
|
+
|
|
34
|
+
def render(erb_source, **locals)
|
|
35
|
+
ctx = Object.new
|
|
36
|
+
locals.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
|
|
37
|
+
pipeline.call(erb_source, ctx.instance_eval { binding })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "plain HTML" do
|
|
41
|
+
it "serializes a plain HTML element" do
|
|
42
|
+
output = render('<div class="hello"><p>World</p></div>')
|
|
43
|
+
expect(output).to match(/0:\["\$","div"/)
|
|
44
|
+
expect(output).to match(/"className":"hello"/)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "unknown component" do
|
|
49
|
+
it "raises an error when component is not in manifest" do
|
|
50
|
+
expect { render("<Button />") }.to raise_error(an_object_satisfying { |e|
|
|
51
|
+
e.message.include?("not found in manifest")
|
|
52
|
+
})
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe "client component with props" do
|
|
57
|
+
it "emits I row, serializes props, and puts root at ID 0" do
|
|
58
|
+
output = render("<div><LikeButton postId={@post_id} initialCount={5} /></div>",
|
|
59
|
+
post_id: 42)
|
|
60
|
+
|
|
61
|
+
expect(output).to match(/I\[/)
|
|
62
|
+
expect(output).to match(/LikeButton/)
|
|
63
|
+
expect(output).to match(/"postId":42/)
|
|
64
|
+
expect(output).to match(/"initialCount":5/)
|
|
65
|
+
expect(output.lines.last).to start_with("0:"), "last row should be root (id=0)"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe "import row ordering" do
|
|
70
|
+
it "emits the I row before the root model row" do
|
|
71
|
+
output = render("<LikeButton postId={1} />")
|
|
72
|
+
lines = output.lines.map(&:strip).reject(&:empty?)
|
|
73
|
+
|
|
74
|
+
import_idx = lines.index { |l| l.include?(":I[") }
|
|
75
|
+
model_idx = lines.index { |l| l.start_with?("0:") }
|
|
76
|
+
|
|
77
|
+
expect(import_idx).to be < model_idx, "I row must come before the root model row"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe "ERB instance variables" do
|
|
82
|
+
it "evaluates ERB and passes instance variables into the output" do
|
|
83
|
+
output = render("<p><%= @title %></p>", title: "Hello RSC")
|
|
84
|
+
expect(output).to match(/"Hello RSC"/)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe "thread safety (NFR8)" do
|
|
89
|
+
it "isolates component state across 10 concurrent renders" do
|
|
90
|
+
results = Array.new(10)
|
|
91
|
+
mutex = Mutex.new
|
|
92
|
+
errors = []
|
|
93
|
+
|
|
94
|
+
threads = Array.new(10) do |i|
|
|
95
|
+
Thread.new do
|
|
96
|
+
ctx = Object.new
|
|
97
|
+
ctx.instance_variable_set(:@index, i)
|
|
98
|
+
output = pipeline.call(
|
|
99
|
+
"<LikeButton postId={@index} />",
|
|
100
|
+
ctx.instance_eval { binding }
|
|
101
|
+
)
|
|
102
|
+
mutex.synchronize { results[i] = output }
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
mutex.synchronize { errors << e }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
threads.each(&:join)
|
|
109
|
+
|
|
110
|
+
expect(errors).to be_empty, "Threads raised: #{errors.map(&:message).join(', ')}"
|
|
111
|
+
|
|
112
|
+
10.times do |i|
|
|
113
|
+
expect(results[i]).to include("\"postId\":#{i}"),
|
|
114
|
+
"Thread #{i} must contain postId=#{i} — got: #{results[i].inspect}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe "determinism (NFR16)" do
|
|
120
|
+
it "produces identical byte output on repeated renders of the same view" do
|
|
121
|
+
first = render("<div><LikeButton postId={1} /></div>")
|
|
122
|
+
second = render("<div><LikeButton postId={1} /></div>")
|
|
123
|
+
expect(first).to eq(second)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "produces different output for different input data" do
|
|
127
|
+
output_a = render("<LikeButton postId={1} />")
|
|
128
|
+
output_b = render("<LikeButton postId={2} />")
|
|
129
|
+
expect(output_a).not_to eq(output_b)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- from_html — ActionView integration path (Story 1.6) ---
|
|
134
|
+
|
|
135
|
+
describe "#from_html" do
|
|
136
|
+
let(:navbar_manifest) do
|
|
137
|
+
ClientManifest.from_hash({
|
|
138
|
+
"NavBar" => {
|
|
139
|
+
"id" => "/NavBar.jsx",
|
|
140
|
+
"name" => "NavBar",
|
|
141
|
+
"chunks" => ["/NavBar.jsx"]
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "converts pre-rendered HTML with component placeholders to Flight rows" do
|
|
147
|
+
pipeline = described_class.new(navbar_manifest)
|
|
148
|
+
ComponentRegistry.start
|
|
149
|
+
token = ComponentRegistry.register("NavBar", { "currentUser" => 42 })
|
|
150
|
+
html = "<div><!-- #{token} --></div>"
|
|
151
|
+
|
|
152
|
+
output = pipeline.from_html(html).to_a.join
|
|
153
|
+
ComponentRegistry.reset
|
|
154
|
+
|
|
155
|
+
expect(output).to include("NavBar")
|
|
156
|
+
expect(output).to include('"currentUser":42')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "eagerly captures registry so ComponentRegistry can be reset before Enumerator is consumed" do
|
|
160
|
+
pipeline = described_class.new(navbar_manifest)
|
|
161
|
+
ComponentRegistry.start
|
|
162
|
+
ComponentRegistry.register("NavBar", { "currentUser" => 1 })
|
|
163
|
+
token = ComponentRegistry.components.first[:token]
|
|
164
|
+
html = "<div><!-- #{token} --></div>"
|
|
165
|
+
|
|
166
|
+
enumerator = pipeline.from_html(html)
|
|
167
|
+
ComponentRegistry.reset # Reset BEFORE consuming the enumerator
|
|
168
|
+
|
|
169
|
+
expect { enumerator.to_a }.not_to raise_error
|
|
170
|
+
expect(enumerator.to_a.join).to include("NavBar")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "produces the same Flight output as call() for equivalent input" do
|
|
174
|
+
# Build the same HTML that RenderPipeline#call would produce for <NavBar />
|
|
175
|
+
# and verify from_html produces the same Flight output.
|
|
176
|
+
pipeline_erb = described_class.new(navbar_manifest)
|
|
177
|
+
pipeline_html = described_class.new(navbar_manifest)
|
|
178
|
+
|
|
179
|
+
pipeline_erb.call("<NavBar currentUser={1} />", binding)
|
|
180
|
+
|
|
181
|
+
ComponentRegistry.start
|
|
182
|
+
ComponentRegistry.register("NavBar", { "currentUser" => 1 })
|
|
183
|
+
token = ComponentRegistry.components.first[:token]
|
|
184
|
+
html = "<!-- #{token} -->"
|
|
185
|
+
html_output = pipeline_html.from_html(html).to_a.join
|
|
186
|
+
ComponentRegistry.reset
|
|
187
|
+
|
|
188
|
+
# Both should contain the NavBar import row and a root model row
|
|
189
|
+
expect(html_output).to include("NavBar")
|
|
190
|
+
expect(html_output).to match(/0:\[/)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "returns an Enumerator (lazy)" do
|
|
194
|
+
pipeline = described_class.new(manifest)
|
|
195
|
+
ComponentRegistry.start
|
|
196
|
+
html = "<div><p>Hello</p></div>"
|
|
197
|
+
|
|
198
|
+
result = pipeline.from_html(html)
|
|
199
|
+
ComponentRegistry.reset
|
|
200
|
+
|
|
201
|
+
expect(result).to be_a(Enumerator)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- Dual-path resolution specs (Story 1.5) ---
|
|
206
|
+
|
|
207
|
+
describe "dual-path resolution via controller_path" do
|
|
208
|
+
let(:dual_manifest) do
|
|
209
|
+
ClientManifest.from_hash({
|
|
210
|
+
"LikeButton" => {
|
|
211
|
+
"id" => "/LikeButton.jsx",
|
|
212
|
+
"name" => "LikeButton",
|
|
213
|
+
"chunks" => ["/LikeButton.jsx"]
|
|
214
|
+
},
|
|
215
|
+
"posts/_like_button" => {
|
|
216
|
+
"id" => "/posts/_like_button.jsx",
|
|
217
|
+
"name" => "default",
|
|
218
|
+
"chunks" => ["/posts/_like_button.jsx"]
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "uses co-located component when controller_path matches (AC#2, AC#3)" do
|
|
224
|
+
pipeline = described_class.new(dual_manifest, controller_path: "posts")
|
|
225
|
+
ctx = Object.new
|
|
226
|
+
output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
|
|
227
|
+
expect(output).to include("/posts/_like_button.jsx")
|
|
228
|
+
expect(output).not_to include("/LikeButton.jsx")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "uses shared component when no controller_path given (AC#1)" do
|
|
232
|
+
pipeline = described_class.new(dual_manifest)
|
|
233
|
+
ctx = Object.new
|
|
234
|
+
output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
|
|
235
|
+
expect(output).to include("/LikeButton.jsx")
|
|
236
|
+
expect(output).not_to include("/posts/_like_button.jsx")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "falls back to shared when controller_path has no co-located key (AC#4)" do
|
|
240
|
+
pipeline = described_class.new(dual_manifest, controller_path: "comments")
|
|
241
|
+
ctx = Object.new
|
|
242
|
+
output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
|
|
243
|
+
expect(output).to include("/LikeButton.jsx")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# --- Prop type integration specs (AC#1–#7) ---
|
|
248
|
+
|
|
249
|
+
describe "prop types via ERB (AC#1–#7)" do
|
|
250
|
+
it "integer prop is a JSON number, not a string (AC#1)" do
|
|
251
|
+
output = render("<LikeButton postId={@count} />", count: 42)
|
|
252
|
+
expect(output).to include('"postId":42')
|
|
253
|
+
expect(output).not_to include('"postId":"42"')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "string prop is a JSON string (AC#2)" do
|
|
257
|
+
output = render("<LikeButton label={@title} />", title: "hello")
|
|
258
|
+
expect(output).to include('"label":"hello"')
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "dollar-prefixed string is escaped with one extra $ (AC#3)" do
|
|
262
|
+
output = render("<LikeButton label={@price} />", price: "$9.99")
|
|
263
|
+
expect(output).to include('"label":"$$9.99"')
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "boolean true prop is a JSON boolean literal (AC#4)" do
|
|
267
|
+
output = render("<LikeButton enabled={true} />")
|
|
268
|
+
expect(output).to include('"enabled":true')
|
|
269
|
+
expect(output).not_to include('"enabled":"true"')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "boolean false prop is a JSON boolean literal (AC#4)" do
|
|
273
|
+
output = render("<LikeButton active={false} />")
|
|
274
|
+
expect(output).to include('"active":false')
|
|
275
|
+
expect(output).not_to include('"active":"false"')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "nil prop becomes JSON null (AC#5)" do
|
|
279
|
+
output = render("<LikeButton value={nil} />")
|
|
280
|
+
expect(output).to include('"value":null')
|
|
281
|
+
expect(output).not_to include('"value":"nil"')
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it "array prop has correctly typed elements (AC#6)" do
|
|
285
|
+
output = render("<LikeButton items={[1, \"a\", true, nil]} />")
|
|
286
|
+
expect(output).to include('"items":[1,"a",true,null]')
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "hash prop produces a JSON object with correct types (AC#7)" do
|
|
290
|
+
output = render("<LikeButton opts={{debug: true, count: 5, label: \"x\"}} />")
|
|
291
|
+
expect(output).to include('"opts":{"debug":true,"count":5,"label":"x"}')
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# --- Loop / local variables spec (AC#8) ---
|
|
296
|
+
|
|
297
|
+
describe "loop — local variables as props (AC#8)" do
|
|
298
|
+
let(:post_struct) { Struct.new(:title, :id) }
|
|
299
|
+
let(:posts) do
|
|
300
|
+
[post_struct.new("First", 1), post_struct.new("Second", 2), post_struct.new("Third", 3)]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
let(:loop_output) do
|
|
304
|
+
pipeline_with_cards = described_class.new(manifest_with_post_card)
|
|
305
|
+
ctx = Object.new
|
|
306
|
+
ctx.instance_variable_set(:@posts, posts)
|
|
307
|
+
pipeline_with_cards.call(
|
|
308
|
+
"<% @posts.each do |post| %><PostCard title={post.title} id={post.id} /><% end %>",
|
|
309
|
+
ctx.instance_eval { binding }
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "each PostCard receives its correct title prop" do
|
|
314
|
+
expect(loop_output).to include('"title":"First"')
|
|
315
|
+
expect(loop_output).to include('"title":"Second"')
|
|
316
|
+
expect(loop_output).to include('"title":"Third"')
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "each PostCard receives its correct id prop as a JSON number" do
|
|
320
|
+
expect(loop_output).to include('"id":1')
|
|
321
|
+
expect(loop_output).to include('"id":2')
|
|
322
|
+
expect(loop_output).to include('"id":3')
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# --- Story 2.2: as_json integration ---
|
|
327
|
+
|
|
328
|
+
describe "as_json integration (Story 2.2)" do
|
|
329
|
+
let(:as_json_manifest) do
|
|
330
|
+
ClientManifest.from_hash({
|
|
331
|
+
"PostCard" => {
|
|
332
|
+
"id" => "/assets/PostCard-abc.js",
|
|
333
|
+
"name" => "PostCard",
|
|
334
|
+
"chunks" => ["/assets/PostCard-abc.js"]
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
end
|
|
338
|
+
let(:fake_logger) { instance_double(::Logger, warn: nil) }
|
|
339
|
+
let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
|
|
340
|
+
|
|
341
|
+
let(:post_like_object) do
|
|
342
|
+
Class.new do
|
|
343
|
+
def as_json
|
|
344
|
+
{ "id" => 1, "title" => "Hello" }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def self.name
|
|
348
|
+
"Post"
|
|
349
|
+
end
|
|
350
|
+
end.new
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "serializes as_json object as a JSON object prop (AC#1)" do
|
|
354
|
+
output = render("<PostCard post={@the_post} />", the_post: post_like_object)
|
|
355
|
+
expect(output).to include('"id":1')
|
|
356
|
+
expect(output).to include('"title":"Hello"')
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it "emits [ruact] warning via the injected logger (AC#1, #3)" do
|
|
360
|
+
render("<PostCard post={@the_post} />", the_post: post_like_object)
|
|
361
|
+
expect(fake_logger).to have_received(:warn)
|
|
362
|
+
.with(match(/\[ruact\] WARNING: Post serialized via as_json/))
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it "warning includes attribute names (AC#1)" do
|
|
366
|
+
render("<PostCard post={@the_post} />", the_post: post_like_object)
|
|
367
|
+
expect(fake_logger).to have_received(:warn)
|
|
368
|
+
.with(match(/ALL attributes exposed to client: id, title/))
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
context "with strict_serialization pipeline" do
|
|
372
|
+
let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
|
|
373
|
+
|
|
374
|
+
it "raises SerializationError when strict_serialization: true (AC#2)" do
|
|
375
|
+
allow(Ruact.config).to receive(:strict_serialization).and_return(true)
|
|
376
|
+
expect { render("<PostCard post={@the_post} />", the_post: post_like_object) }
|
|
377
|
+
.to raise_error(Ruact::SerializationError, /strict_serialization/)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# --- Story 2.3: Serializable integration ---
|
|
383
|
+
|
|
384
|
+
describe "Serializable integration (Story 2.3)" do
|
|
385
|
+
let(:serializable_manifest) do
|
|
386
|
+
ClientManifest.from_hash({
|
|
387
|
+
"PostCard" => {
|
|
388
|
+
"id" => "/assets/PostCard-abc.js",
|
|
389
|
+
"name" => "PostCard",
|
|
390
|
+
"chunks" => ["/assets/PostCard-abc.js"]
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
end
|
|
394
|
+
let(:fake_logger_s) { instance_double(::Logger, warn: nil) }
|
|
395
|
+
let(:pipeline) { described_class.new(serializable_manifest, logger: fake_logger_s) }
|
|
396
|
+
|
|
397
|
+
let(:serializable_post) do
|
|
398
|
+
Class.new do
|
|
399
|
+
include Ruact::Serializable
|
|
400
|
+
|
|
401
|
+
attr_reader :id, :title
|
|
402
|
+
|
|
403
|
+
def initialize
|
|
404
|
+
@id = 1
|
|
405
|
+
@title = "Hello"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def self.name
|
|
409
|
+
"Post"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
rsc_props :id, :title
|
|
413
|
+
end.new
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
it "serializes only declared props (AC#1)" do
|
|
417
|
+
output = render("<PostCard post={@the_post} />", the_post: serializable_post)
|
|
418
|
+
expect(output).to include('"id":1', '"title":"Hello"')
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it "does NOT emit [ruact] warning (AC#3)" do
|
|
422
|
+
render("<PostCard post={@the_post} />", the_post: serializable_post)
|
|
423
|
+
expect(fake_logger_s).not_to have_received(:warn)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# --- Story 2.1: useState/useEffect/event handler prop types ---
|
|
428
|
+
|
|
429
|
+
describe "client components with hook prop types (Story 2.1)" do
|
|
430
|
+
let(:manifest_with_hooks) do
|
|
431
|
+
ClientManifest.from_hash({
|
|
432
|
+
"CounterButton" => {
|
|
433
|
+
"id" => "/assets/CounterButton-abc.js",
|
|
434
|
+
"name" => "CounterButton",
|
|
435
|
+
"chunks" => ["/assets/CounterButton-abc.js"]
|
|
436
|
+
},
|
|
437
|
+
"SearchInput" => {
|
|
438
|
+
"id" => "/assets/SearchInput-abc.js",
|
|
439
|
+
"name" => "SearchInput",
|
|
440
|
+
"chunks" => ["/assets/SearchInput-abc.js"]
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
end
|
|
444
|
+
let(:pipeline) { described_class.new(manifest_with_hooks) }
|
|
445
|
+
|
|
446
|
+
it "serializes string prop (AC#3 — useState initial string, onChange placeholder)" do
|
|
447
|
+
output = render('<SearchInput placeholder={"Search..."} />')
|
|
448
|
+
expect(output).to match(/"placeholder":"Search\.\.\."/)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it "serializes boolean true prop for useState initial value (AC#1)" do
|
|
452
|
+
output = render("<CounterButton enabled={true} />")
|
|
453
|
+
expect(output).to match(/"enabled":true/)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "serializes boolean false prop for disabled state (AC#1)" do
|
|
457
|
+
output = render("<CounterButton disabled={false} />")
|
|
458
|
+
expect(output).to match(/"disabled":false/)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
it "serializes nil prop as JSON null — no hydration mismatch for absent optionals (AC#4)" do
|
|
462
|
+
output = render("<CounterButton initialCount={nil} />")
|
|
463
|
+
expect(output).to match(/"initialCount":null/)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it "serializes mixed props (integer + string + boolean) in a single component (AC#1, #3, #4)" do
|
|
467
|
+
output = render('<CounterButton initialCount={0} label={"Votes"} disabled={false} />')
|
|
468
|
+
expect(output).to match(/"initialCount":0/)
|
|
469
|
+
expect(output).to match(/"label":"Votes"/)
|
|
470
|
+
expect(output).to match(/"disabled":false/)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
RSpec.describe Serializable do
|
|
7
|
+
let(:serializable_class) do
|
|
8
|
+
Class.new do
|
|
9
|
+
include Ruact::Serializable
|
|
10
|
+
|
|
11
|
+
attr_reader :id, :title, :secret
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@id = 1
|
|
15
|
+
@title = "Hello"
|
|
16
|
+
@secret = "top-secret"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
rsc_props :id, :title
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe "rsc_props (AC#2)" do
|
|
24
|
+
it "raises ArgumentError for undefined method at class load time" do
|
|
25
|
+
expect do
|
|
26
|
+
Class.new do
|
|
27
|
+
include Ruact::Serializable
|
|
28
|
+
|
|
29
|
+
rsc_props :nonexistent
|
|
30
|
+
end
|
|
31
|
+
end.to raise_error(ArgumentError, /nonexistent/)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe "rsc_serialize (AC#1)" do
|
|
36
|
+
it "returns only declared props" do
|
|
37
|
+
obj = serializable_class.new
|
|
38
|
+
expect(obj.rsc_serialize).to eq({ "id" => 1, "title" => "Hello" })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "excludes undeclared attributes" do
|
|
42
|
+
obj = serializable_class.new
|
|
43
|
+
expect(obj.rsc_serialize.keys).not_to include("secret")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe "rsc_props_list" do
|
|
48
|
+
it "returns the declared prop names as symbols" do
|
|
49
|
+
expect(serializable_class.rsc_props_list).to eq(%i[id title])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "active_support/core_ext/string/output_safety"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
RSpec.describe ViewHelper do
|
|
8
|
+
let(:helper_obj) do
|
|
9
|
+
obj = Object.new
|
|
10
|
+
obj.extend(described_class)
|
|
11
|
+
obj
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before { ComponentRegistry.start }
|
|
15
|
+
after { ComponentRegistry.reset }
|
|
16
|
+
|
|
17
|
+
describe "#__rsc_component__" do
|
|
18
|
+
it "registers the component in ComponentRegistry and returns an HTML comment" do
|
|
19
|
+
result = helper_obj.__rsc_component__("NavBar", { "currentUser" => 1 })
|
|
20
|
+
expect(result).to match(/<!-- __RSC_\d+__ -->/)
|
|
21
|
+
expect(ComponentRegistry.components.length).to eq(1)
|
|
22
|
+
expect(ComponentRegistry.components.first[:name]).to eq("NavBar")
|
|
23
|
+
expect(ComponentRegistry.components.first[:props]).to eq({ "currentUser" => 1 })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "returns an html_safe string so ActionView does not escape the comment" do
|
|
27
|
+
result = helper_obj.__rsc_component__("Button", {})
|
|
28
|
+
expect(result).to be_html_safe
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "uses incrementing token numbers for successive registrations" do
|
|
32
|
+
token0 = helper_obj.__rsc_component__("Foo", {})
|
|
33
|
+
token1 = helper_obj.__rsc_component__("Bar", {})
|
|
34
|
+
expect(token0).to include("__RSC_0__")
|
|
35
|
+
expect(token1).to include("__RSC_1__")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "passes props through to the registry entry" do
|
|
39
|
+
helper_obj.__rsc_component__("LikeButton", { "postId" => 42, "label" => "Like" })
|
|
40
|
+
entry = ComponentRegistry.components.first
|
|
41
|
+
expect(entry[:props]["postId"]).to eq(42)
|
|
42
|
+
expect(entry[:props]["label"]).to eq("Like")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "ruact"
|
|
5
|
+
|
|
6
|
+
Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f }
|
|
7
|
+
|
|
8
|
+
RSpec.configure do |config|
|
|
9
|
+
config.order = :random
|
|
10
|
+
config.expect_with :rspec do |expectations|
|
|
11
|
+
expectations.max_formatted_output_length = 2000
|
|
12
|
+
end
|
|
13
|
+
config.mock_with :rspec do |mocks|
|
|
14
|
+
mocks.verify_partial_doubles = true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec::Matchers.define :match_flight_fixture do |name|
|
|
4
|
+
match do |actual|
|
|
5
|
+
fixtures_dir = File.expand_path("../../fixtures/flight", __dir__)
|
|
6
|
+
fixture_path = File.join(fixtures_dir, "#{name}.txt")
|
|
7
|
+
@fixture_path = fixture_path
|
|
8
|
+
@expected = File.read(fixture_path)
|
|
9
|
+
actual == @expected
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
failure_message do |actual|
|
|
13
|
+
"Expected output to match fixture at #{@fixture_path}.\n\n" \
|
|
14
|
+
"Expected:\n#{@expected.inspect}\n\n" \
|
|
15
|
+
"Got:\n#{actual.inspect}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failure_message_when_negated do |_actual|
|
|
19
|
+
"Expected output NOT to match fixture at #{@fixture_path}, but it did."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
description do
|
|
23
|
+
"match Flight wire fixture '#{name}'"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal Rails stub for specs that need Rails without it being in the bundle.
|
|
4
|
+
# Loaded automatically by spec_helper. Does nothing if Rails is already defined.
|
|
5
|
+
return if defined?(Rails)
|
|
6
|
+
|
|
7
|
+
# Prevent `require "rails"` inside loaded files from failing.
|
|
8
|
+
$LOADED_FEATURES << "rails.rb" unless $LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }
|
|
9
|
+
|
|
10
|
+
module Rails
|
|
11
|
+
class Railtie
|
|
12
|
+
def self.initializer(*, **); end
|
|
13
|
+
def self.rake_tasks(&); end
|
|
14
|
+
|
|
15
|
+
def self.config
|
|
16
|
+
@config ||= Class.new do
|
|
17
|
+
def method_missing(name, *, **, &); end
|
|
18
|
+
|
|
19
|
+
def respond_to_missing?(*, **)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_accessor :env, :logger, :root
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module ActiveSupport # rubocop:disable Style/OneClassPerFile
|
|
32
|
+
class StringInquirer < String
|
|
33
|
+
def method_missing(method_name, *args)
|
|
34
|
+
if method_name.to_s.end_with?("?")
|
|
35
|
+
self == method_name.to_s.chomp("?")
|
|
36
|
+
else
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def respond_to_missing?(*, **)
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|