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,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
|