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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +166 -0
  3. data/.rubocop.yml +89 -0
  4. data/CHANGELOG.md +32 -0
  5. data/README.md +35 -0
  6. data/RELEASING.md +203 -0
  7. data/Rakefile +10 -0
  8. data/SECURITY.md +62 -0
  9. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  10. data/lib/generators/ruact/install/install_generator.rb +100 -0
  11. data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
  12. data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
  13. data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
  14. data/lib/ruact/client_manifest.rb +115 -0
  15. data/lib/ruact/component_registry.rb +31 -0
  16. data/lib/ruact/configuration.rb +32 -0
  17. data/lib/ruact/controller.rb +195 -0
  18. data/lib/ruact/doctor.rb +84 -0
  19. data/lib/ruact/erb_preprocessor.rb +120 -0
  20. data/lib/ruact/erb_preprocessor_hook.rb +20 -0
  21. data/lib/ruact/errors.rb +14 -0
  22. data/lib/ruact/flight/react_element.rb +40 -0
  23. data/lib/ruact/flight/renderer.rb +73 -0
  24. data/lib/ruact/flight/request.rb +54 -0
  25. data/lib/ruact/flight/row_emitter.rb +37 -0
  26. data/lib/ruact/flight/serializer.rb +215 -0
  27. data/lib/ruact/flight.rb +12 -0
  28. data/lib/ruact/html_converter.rb +159 -0
  29. data/lib/ruact/railtie.rb +99 -0
  30. data/lib/ruact/render_pipeline.rb +107 -0
  31. data/lib/ruact/serializable.rb +58 -0
  32. data/lib/ruact/version.rb +5 -0
  33. data/lib/ruact/view_helper.rb +23 -0
  34. data/lib/ruact.rb +48 -0
  35. data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
  36. data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
  37. data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
  38. data/lib/rubocop/cop/ruact.rb +5 -0
  39. data/lib/tasks/benchmark.rake +70 -0
  40. data/lib/tasks/rsc.rake +9 -0
  41. data/sig/ruact.rbs +4 -0
  42. data/spec/benchmarks/baseline.json +1 -0
  43. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
  44. data/spec/fixtures/flight/README.md +88 -0
  45. data/spec/fixtures/flight/array.txt +1 -0
  46. data/spec/fixtures/flight/as_json_object.txt +2 -0
  47. data/spec/fixtures/flight/boolean_false.txt +1 -0
  48. data/spec/fixtures/flight/boolean_true.txt +1 -0
  49. data/spec/fixtures/flight/client_component_with_props.txt +2 -0
  50. data/spec/fixtures/flight/client_reference.txt +2 -0
  51. data/spec/fixtures/flight/hash.txt +1 -0
  52. data/spec/fixtures/flight/nil.txt +1 -0
  53. data/spec/fixtures/flight/number_float.txt +1 -0
  54. data/spec/fixtures/flight/number_integer.txt +1 -0
  55. data/spec/fixtures/flight/react_element_no_props.txt +1 -0
  56. data/spec/fixtures/flight/redirect_row.txt +1 -0
  57. data/spec/fixtures/flight/serializable_object.txt +2 -0
  58. data/spec/fixtures/flight/string_basic.txt +1 -0
  59. data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
  60. data/spec/ruact/client_manifest_spec.rb +126 -0
  61. data/spec/ruact/controller_spec.rb +213 -0
  62. data/spec/ruact/doctor_spec.rb +234 -0
  63. data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
  64. data/spec/ruact/erb_preprocessor_spec.rb +89 -0
  65. data/spec/ruact/errors_spec.rb +43 -0
  66. data/spec/ruact/flight/renderer_spec.rb +122 -0
  67. data/spec/ruact/flight/serializer_spec.rb +453 -0
  68. data/spec/ruact/html_converter_spec.rb +147 -0
  69. data/spec/ruact/install_generator_spec.rb +212 -0
  70. data/spec/ruact/railtie_spec.rb +156 -0
  71. data/spec/ruact/render_pipeline_spec.rb +474 -0
  72. data/spec/ruact/serializable_spec.rb +53 -0
  73. data/spec/ruact/view_helper_spec.rb +46 -0
  74. data/spec/spec_helper.rb +16 -0
  75. data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
  76. data/spec/support/rails_stub.rb +45 -0
  77. data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  78. 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
@@ -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