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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ module Flight
7
+ RSpec.describe Renderer do
8
+ let(:empty_manifest) { ClientManifest.from_hash({}) }
9
+ let(:manifest_with_button) do
10
+ ClientManifest.from_hash({
11
+ "Button" => {
12
+ "id" => "/assets/Button-abc.js",
13
+ "name" => "Button",
14
+ "chunks" => ["/assets/Button-abc.js"]
15
+ }
16
+ })
17
+ end
18
+
19
+ describe ".render" do
20
+ it "renders ReactElement with no props to fixture" do
21
+ element = ReactElement.new(type: "div")
22
+ output = described_class.render(element, empty_manifest)
23
+ expect(output).to match_flight_fixture("react_element_no_props")
24
+ end
25
+
26
+ it "returns a String" do
27
+ element = ReactElement.new(type: "span")
28
+ expect(described_class.render(element, empty_manifest)).to be_a(String)
29
+ end
30
+
31
+ it "root row always has id 0" do
32
+ element = ReactElement.new(type: "p")
33
+ output = described_class.render(element, empty_manifest)
34
+ expect(output.lines.last).to start_with("0:")
35
+ end
36
+ end
37
+
38
+ describe ".each" do
39
+ it "yields rows when called with a block" do
40
+ element = ReactElement.new(type: "div")
41
+ rows = []
42
+ described_class.each(element, empty_manifest) { |row| rows << row }
43
+ expect(rows).not_to be_empty
44
+ end
45
+
46
+ it "produces identical output to .render" do
47
+ element = ReactElement.new(type: "article")
48
+ via_each = described_class.each(element, empty_manifest, streaming: false).to_a.join
49
+ via_render = described_class.render(element, empty_manifest)
50
+ expect(via_each).to eq(via_render)
51
+ end
52
+
53
+ it "returns Enumerator when called without block" do
54
+ element = ReactElement.new(type: "div")
55
+ result = described_class.each(element, empty_manifest)
56
+ expect(result).to be_an(Enumerator)
57
+ end
58
+ end
59
+
60
+ describe "suspense timeout (Story 3.5 AC #4)" do
61
+ let(:fallback) { ReactElement.new(type: "span") }
62
+ let(:inner) { ReactElement.new(type: "div") }
63
+
64
+ context "when deferred delay exceeds suspense_timeout (streaming: true)" do
65
+ before do
66
+ allow(Ruact.config).to receive(:suspense_timeout).and_return(1.0)
67
+ end
68
+
69
+ it "emits an E-type error row instead of the model row" do
70
+ suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
71
+ rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
72
+ expect(rows).to include(a_string_matching(/:E/))
73
+ end
74
+
75
+ it "error row contains 'Suspense timeout exceeded'" do
76
+ suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
77
+ rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
78
+ error_row = rows.find { |r| r.include?(":E") }
79
+ expect(error_row).to include("Suspense timeout exceeded")
80
+ end
81
+
82
+ it "does NOT emit a plain model row for the timed-out chunk" do
83
+ suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
84
+ rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
85
+ # Only root (0:) and the error row (1:E...) should exist — no bare model for deferred id
86
+ unexpected = rows.reject { |r| r.start_with?("0:") || r.include?(":E") }
87
+ expect(unexpected).to be_empty
88
+ end
89
+ end
90
+
91
+ context "when deferred delay is within suspense_timeout (streaming: true)" do
92
+ before do
93
+ allow(Ruact.config).to receive(:suspense_timeout).and_return(10.0)
94
+ end
95
+
96
+ it "emits a model row (not an error row) and no E row" do
97
+ suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 0.0)
98
+ rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
99
+ expect(rows).not_to include(a_string_matching(/:E/))
100
+ end
101
+ end
102
+ end
103
+
104
+ describe "import row ordering" do
105
+ it "emits import rows before the root model row" do
106
+ element = ReactElement.new(
107
+ type: ClientReference.new(module_id: "/assets/Button-abc.js", export_name: "Button"),
108
+ props: {}
109
+ )
110
+ output = described_class.render(element, manifest_with_button)
111
+ lines = output.lines.map(&:strip).reject(&:empty?)
112
+
113
+ import_idx = lines.index { |l| l.include?(":I[") }
114
+ root_idx = lines.index { |l| l.start_with?("0:") }
115
+
116
+ expect(import_idx).not_to be_nil
117
+ expect(import_idx).to be < root_idx
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ module Flight
7
+ # Stub bundler config — resolves any module_id/export_name to a simple metadata hash
8
+ class StubBundlerConfig
9
+ def resolve(module_id, export_name)
10
+ [module_id, export_name]
11
+ end
12
+ end
13
+
14
+ RSpec.describe "Renderer (serializer integration)" do
15
+ subject(:render) { ->(model) { Renderer.render(model, bundler) } }
16
+
17
+ let(:bundler) { StubBundlerConfig.new }
18
+
19
+ # --- Primitives ---
20
+
21
+ describe "strings" do
22
+ it "serializes a plain string" do
23
+ expect(render.call("hello")).to eq("0:\"hello\"\n")
24
+ end
25
+
26
+ it "escapes strings starting with $" do
27
+ expect(render.call("$danger")).to eq("0:\"$$danger\"\n")
28
+ end
29
+ end
30
+
31
+ describe "numbers" do
32
+ it "serializes an integer" do
33
+ expect(render.call(42)).to eq("0:42\n")
34
+ end
35
+
36
+ it "serializes a bigint (> MAX_SAFE_INTEGER) as $n<decimal>" do
37
+ big = 9_007_199_254_740_992
38
+ expect(render.call(big)).to eq("0:\"$n#{big}\"\n")
39
+ end
40
+
41
+ it "serializes Float::NAN" do
42
+ expect(render.call(Float::NAN)).to eq("0:\"$NaN\"\n")
43
+ end
44
+
45
+ it "serializes Float::INFINITY" do
46
+ expect(render.call(Float::INFINITY)).to eq("0:\"$Infinity\"\n")
47
+ end
48
+
49
+ it "serializes -Float::INFINITY" do
50
+ expect(render.call(-Float::INFINITY)).to eq("0:\"$-Infinity\"\n")
51
+ end
52
+ end
53
+
54
+ describe "nil/boolean" do
55
+ it "serializes nil as null" do
56
+ expect(render.call(nil)).to eq("0:null\n")
57
+ end
58
+
59
+ it "serializes true" do
60
+ expect(render.call(true)).to eq("0:true\n")
61
+ end
62
+
63
+ it "serializes false" do
64
+ expect(render.call(false)).to eq("0:false\n")
65
+ end
66
+ end
67
+
68
+ describe "special symbols" do
69
+ it "serializes :undefined as $undefined" do
70
+ expect(render.call(:undefined)).to eq("0:\"$undefined\"\n")
71
+ end
72
+ end
73
+
74
+ # --- Collections ---
75
+
76
+ describe "hash" do
77
+ it "serializes a hash with symbol keys" do
78
+ out = render.call({ greeting: "hello", count: 42 })
79
+ expect(out).to eq("0:{\"greeting\":\"hello\",\"count\":42}\n")
80
+ end
81
+ end
82
+
83
+ describe "array" do
84
+ it "serializes a plain array" do
85
+ expect(render.call([1, 2, 3])).to eq("0:[1,2,3]\n")
86
+ end
87
+ end
88
+
89
+ # --- React Element (DOM) ---
90
+
91
+ describe "ReactElement" do
92
+ it "serializes a DOM element" do
93
+ el = ReactElement.new(type: "div", props: { className: "box", children: "hi" })
94
+ expect(render.call(el)).to eq("0:[\"$\",\"div\",null,{\"className\":\"box\",\"children\":\"hi\"}]\n")
95
+ end
96
+
97
+ it "serializes a DOM element with a key" do
98
+ el = ReactElement.new(type: "li", key: "item-1", props: { children: "one" })
99
+ expect(render.call(el)).to eq("0:[\"$\",\"li\",\"item-1\",{\"children\":\"one\"}]\n")
100
+ end
101
+ end
102
+
103
+ # --- Client Reference ---
104
+
105
+ describe "ClientReference" do
106
+ it "emits an I row for the import and references $L1 in root" do
107
+ ref = ClientReference.new(module_id: "./LikeButton", export_name: "LikeButton")
108
+ el = ReactElement.new(type: ref, props: { postId: 1, initialCount: 5 })
109
+ out = render.call(el)
110
+
111
+ expect(out).to match(%r{1:I\["\./LikeButton","LikeButton"\]})
112
+ expect(out).to match(/0:\["\$","\$L1"/)
113
+ expect(out.index("1:I")).to be < out.index("0:["), "import row must precede model row"
114
+ end
115
+
116
+ it "deduplicates: same ClientReference object emits only one I row" do
117
+ ref = ClientReference.new(module_id: "./Button", export_name: "Button")
118
+ el1 = ReactElement.new(type: ref, props: { label: "Save" })
119
+ el2 = ReactElement.new(type: ref, props: { label: "Cancel" })
120
+ out = render.call([el1, el2])
121
+
122
+ i_rows = out.scan(/\d+:I/).length
123
+ expect(i_rows).to eq(1), "same ClientReference object should emit only one I row"
124
+ end
125
+ end
126
+
127
+ # --- Error handling ---
128
+
129
+ describe "unsupported type (AC#2)" do
130
+ it "raises SerializationError with class name and Serializable hint" do
131
+ expect { render.call(Object.new) }
132
+ .to raise_error(Ruact::SerializationError, /Object/)
133
+ expect { render.call(Object.new) }
134
+ .to raise_error(Ruact::SerializationError, /include Ruact::Serializable/)
135
+ end
136
+ end
137
+
138
+ # --- Story 2.2: as_json serialization ---
139
+
140
+ describe "as_json serialization (Story 2.2)" do
141
+ let(:model_with_as_json) do
142
+ Class.new do
143
+ def as_json
144
+ { "id" => 1, "name" => "Alice" }
145
+ end
146
+
147
+ def self.name
148
+ "FakeModel"
149
+ end
150
+ end.new
151
+ end
152
+
153
+ let(:model_without_as_json) { Object.new }
154
+
155
+ context "with strict_serialization: false (default)" do
156
+ let(:render_loose) do
157
+ b = StubBundlerConfig.new
158
+ ->(model) { Renderer.render(model, b, strict_serialization: false) }
159
+ end
160
+
161
+ it "serializes via as_json, returning a hash (AC#3)" do
162
+ expect(render_loose.call(model_with_as_json)).to include('"id":1')
163
+ expect(render_loose.call(model_with_as_json)).to include('"name":"Alice"')
164
+ end
165
+
166
+ it "calls on_as_json_warning with class name and attribute list (AC#1, #3)" do
167
+ warnings = []
168
+ b = StubBundlerConfig.new
169
+ Renderer.render(model_with_as_json, b,
170
+ strict_serialization: false,
171
+ on_as_json_warning: ->(cls, attrs) { warnings << [cls, attrs] })
172
+ expect(warnings).to eq([["FakeModel", "id, name"]])
173
+ end
174
+
175
+ it "does NOT call on_as_json_warning when callback is nil (AC#3)" do
176
+ expect { render_loose.call(model_with_as_json) }.not_to raise_error
177
+ end
178
+ end
179
+
180
+ context "with strict_serialization: true" do
181
+ let(:render_strict) do
182
+ b = StubBundlerConfig.new
183
+ ->(model) { Renderer.render(model, b, strict_serialization: true) }
184
+ end
185
+
186
+ it "raises SerializationError with Serializable hint (AC#2)" do
187
+ expect { render_strict.call(model_with_as_json) }
188
+ .to raise_error(Ruact::SerializationError, /FakeModel/)
189
+ expect { render_strict.call(model_with_as_json) }
190
+ .to raise_error(Ruact::SerializationError,
191
+ /include Ruact::Serializable or set strict_serialization: false/)
192
+ end
193
+ end
194
+
195
+ context "with as_json returning non-Hash (e.g. array)" do
196
+ let(:model_returning_array) do
197
+ Class.new do
198
+ def as_json
199
+ [1, 2, 3]
200
+ end
201
+
202
+ def self.name
203
+ "ArrayModel"
204
+ end
205
+ end.new
206
+ end
207
+
208
+ it "serializes the array without crashing" do
209
+ b = StubBundlerConfig.new
210
+ output = Renderer.render(model_returning_array, b, strict_serialization: false)
211
+ expect(output).to include("[1,2,3]")
212
+ end
213
+ end
214
+
215
+ context "with as_json returning self" do
216
+ let(:model_returning_self) do
217
+ Class.new do
218
+ def as_json
219
+ self
220
+ end
221
+
222
+ def self.name
223
+ "SelfModel"
224
+ end
225
+ end.new
226
+ end
227
+
228
+ it "raises SerializationError about infinite recursion" do
229
+ b = StubBundlerConfig.new
230
+ expect { Renderer.render(model_returning_self, b, strict_serialization: false) }
231
+ .to raise_error(Ruact::SerializationError, /infinite recursion/)
232
+ end
233
+ end
234
+
235
+ context "with as_json raising an exception" do
236
+ let(:model_raising_error) do
237
+ Class.new do
238
+ def as_json
239
+ raise "broken"
240
+ end
241
+
242
+ def self.name
243
+ "BrokenModel"
244
+ end
245
+ end.new
246
+ end
247
+
248
+ it "wraps the exception in SerializationError" do
249
+ b = StubBundlerConfig.new
250
+ expect { Renderer.render(model_raising_error, b, strict_serialization: false) }
251
+ .to raise_error(Ruact::SerializationError, /BrokenModel#as_json raised RuntimeError: broken/)
252
+ end
253
+ end
254
+
255
+ context "without as_json method" do
256
+ it "raises SerializationError regardless of strict_serialization (AC#4)" do
257
+ b = StubBundlerConfig.new
258
+ [true, false].each do |strict|
259
+ expect { Renderer.render(model_without_as_json, b, strict_serialization: strict) }
260
+ .to raise_error(Ruact::SerializationError, /include Ruact::Serializable/)
261
+ end
262
+ end
263
+ end
264
+ end
265
+
266
+ # --- Story 2.3: Serializable serialization ---
267
+
268
+ describe "Serializable serialization (Story 2.3)" do
269
+ let(:serializable_obj) do
270
+ Class.new do
271
+ include Ruact::Serializable
272
+
273
+ attr_reader :id, :title
274
+
275
+ def initialize
276
+ @id = 1
277
+ @title = "Hello"
278
+ end
279
+
280
+ def self.name
281
+ "FakeSerializable"
282
+ end
283
+
284
+ rsc_props :id, :title
285
+ end.new
286
+ end
287
+
288
+ it "serializes via rsc_serialize — only declared props (AC#1)" do
289
+ expect(render.call(serializable_obj)).to include('"id":1', '"title":"Hello"')
290
+ end
291
+
292
+ it "works with strict_serialization: true — Serializable bypasses strict gate (AC#1)" do
293
+ b = StubBundlerConfig.new
294
+ output = Renderer.render(serializable_obj, b, strict_serialization: true)
295
+ expect(output).to include('"id":1')
296
+ end
297
+
298
+ it "does NOT call on_as_json_warning (AC#3)" do
299
+ warnings = []
300
+ b = StubBundlerConfig.new
301
+ Renderer.render(serializable_obj, b,
302
+ strict_serialization: false,
303
+ on_as_json_warning: ->(cls, attrs) { warnings << [cls, attrs] })
304
+ expect(warnings).to be_empty
305
+ end
306
+ end
307
+
308
+ # --- Fixture Contracts (AC#9) ---
309
+
310
+ describe "fixture contracts (AC#9)" do
311
+ let(:fixture_render) { ->(model) { Renderer.render(model, ClientManifest.from_hash({})) } }
312
+
313
+ it "nil serializes to fixture" do
314
+ expect(fixture_render.call(nil)).to match_flight_fixture("nil")
315
+ end
316
+
317
+ it "true serializes to fixture" do
318
+ expect(fixture_render.call(true)).to match_flight_fixture("boolean_true")
319
+ end
320
+
321
+ it "false serializes to fixture" do
322
+ expect(fixture_render.call(false)).to match_flight_fixture("boolean_false")
323
+ end
324
+
325
+ it "plain string serializes to fixture" do
326
+ expect(fixture_render.call("hello")).to match_flight_fixture("string_basic")
327
+ end
328
+
329
+ it "dollar-prefixed string serializes to fixture" do
330
+ expect(fixture_render.call("$danger")).to match_flight_fixture("string_dollar_escape")
331
+ end
332
+
333
+ it "integer serializes to fixture" do
334
+ expect(fixture_render.call(42)).to match_flight_fixture("number_integer")
335
+ end
336
+
337
+ it "float serializes to fixture" do
338
+ expect(fixture_render.call(3.14)).to match_flight_fixture("number_float")
339
+ end
340
+
341
+ it "mixed array serializes to fixture" do
342
+ expect(fixture_render.call([1, "a", true, nil])).to match_flight_fixture("array")
343
+ end
344
+
345
+ it "hash with symbol keys serializes to fixture" do
346
+ expect(fixture_render.call({ debug: true, count: 5, label: "x" })).to match_flight_fixture("hash")
347
+ end
348
+ end
349
+
350
+ describe "client reference fixture contract (AC#3)" do
351
+ let(:manifest_with_button) do
352
+ ClientManifest.from_hash({
353
+ "LikeButton" => {
354
+ "id" => "/LikeButton.jsx",
355
+ "name" => "LikeButton",
356
+ "chunks" => ["/LikeButton.jsx"]
357
+ }
358
+ })
359
+ end
360
+
361
+ it "client reference element serializes to fixture (AC#3)" do
362
+ ref = manifest_with_button.reference_for("LikeButton")
363
+ el = ReactElement.new(type: ref, props: {})
364
+ expect(Renderer.render(el, manifest_with_button)).to match_flight_fixture("client_reference")
365
+ end
366
+ end
367
+
368
+ describe "as_json object fixture contract (Story 2.2)" do
369
+ let(:postcard_manifest) do
370
+ ClientManifest.from_hash({
371
+ "PostCard" => {
372
+ "id" => "/PostCard.jsx",
373
+ "name" => "PostCard",
374
+ "chunks" => ["/PostCard.jsx"]
375
+ }
376
+ })
377
+ end
378
+ let(:as_json_obj) do
379
+ Class.new do
380
+ def as_json
381
+ { "id" => 1, "title" => "Hello", "author" => "Alice", "likesCount" => 5 }
382
+ end
383
+
384
+ def self.name
385
+ "Post"
386
+ end
387
+ end.new
388
+ end
389
+
390
+ it "component with as_json prop serializes to fixture" do
391
+ ref = postcard_manifest.reference_for("PostCard")
392
+ el = ReactElement.new(type: ref, props: { post: as_json_obj })
393
+ output = Renderer.render(el, postcard_manifest, strict_serialization: false)
394
+ expect(output).to match_flight_fixture("as_json_object")
395
+ end
396
+ end
397
+
398
+ describe "serializable object fixture contract (Story 2.3)" do
399
+ let(:postcard_manifest_s) do
400
+ ClientManifest.from_hash({
401
+ "PostCard" => {
402
+ "id" => "/PostCard.jsx",
403
+ "name" => "PostCard",
404
+ "chunks" => ["/PostCard.jsx"]
405
+ }
406
+ })
407
+ end
408
+ let(:serializable_obj) do
409
+ Class.new do
410
+ include Ruact::Serializable
411
+
412
+ attr_reader :id, :title
413
+
414
+ def initialize
415
+ @id = 1
416
+ @title = "Hello"
417
+ end
418
+
419
+ def self.name
420
+ "Post"
421
+ end
422
+
423
+ rsc_props :id, :title
424
+ end.new
425
+ end
426
+
427
+ it "component with Serializable prop serializes to fixture (AC#4)" do
428
+ ref = postcard_manifest_s.reference_for("PostCard")
429
+ el = ReactElement.new(type: ref, props: { post: serializable_obj })
430
+ expect(Renderer.render(el, postcard_manifest_s)).to match_flight_fixture("serializable_object")
431
+ end
432
+ end
433
+
434
+ describe "client component with props fixture contract (Story 2.1)" do
435
+ let(:counter_manifest) do
436
+ ClientManifest.from_hash({
437
+ "CounterButton" => {
438
+ "id" => "/CounterButton.jsx",
439
+ "name" => "CounterButton",
440
+ "chunks" => ["/CounterButton.jsx"]
441
+ }
442
+ })
443
+ end
444
+
445
+ it "client component with integer+string+boolean props serializes to fixture" do
446
+ ref = counter_manifest.reference_for("CounterButton")
447
+ el = ReactElement.new(type: ref, props: { initialCount: 0, label: "Click me", disabled: false })
448
+ expect(Renderer.render(el, counter_manifest)).to match_flight_fixture("client_component_with_props")
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end