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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "active_support/concern"
5
+ require "ruact/controller"
6
+
7
+ module Ruact
8
+ RSpec.describe Controller do
9
+ let(:test_class) do
10
+ Class.new do
11
+ include Ruact::Controller
12
+
13
+ attr_reader :request
14
+
15
+ def initialize(fake_request)
16
+ @request = fake_request
17
+ end
18
+ end
19
+ end
20
+
21
+ # Minimal test double — `headers` + `format` needed for the methods under test.
22
+ # `format` must be a public struct member (not Kernel#format) so that
23
+ # verify_partial_doubles can stub it in #default_render tests.
24
+ let(:fake_request) { Struct.new(:headers, :format).new({}, nil) }
25
+ let(:controller) { test_class.new(fake_request) }
26
+
27
+ describe "#rsc_manifest" do
28
+ it "reads from Ruact.manifest (AC#6)" do
29
+ test_manifest = ClientManifest.from_hash({})
30
+ allow(Ruact).to receive(:manifest).and_return(test_manifest)
31
+ expect(controller.send(:rsc_manifest)).to be test_manifest
32
+ end
33
+ end
34
+
35
+ describe "#rsc_request?" do
36
+ it "returns true when Accept: text/x-component" do
37
+ fake_request.headers["Accept"] = "text/x-component"
38
+ expect(controller.send(:rsc_request?)).to be true
39
+ end
40
+
41
+ it "returns true when Accept header includes text/x-component alongside other types" do
42
+ fake_request.headers["Accept"] = "text/x-component, */*"
43
+ expect(controller.send(:rsc_request?)).to be true
44
+ end
45
+
46
+ it "returns true when RSC-Request: 1 header is set" do
47
+ fake_request.headers["RSC-Request"] = "1"
48
+ expect(controller.send(:rsc_request?)).to be true
49
+ end
50
+
51
+ it "returns false when Accept: text/html" do
52
+ fake_request.headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
53
+ expect(controller.send(:rsc_request?)).to be false
54
+ end
55
+
56
+ it "returns false when no Accept header is set" do
57
+ expect(controller.send(:rsc_request?)).to be false
58
+ end
59
+ end
60
+
61
+ describe "#default_render" do
62
+ let(:html_format) { Class.new { def html? = true }.new }
63
+ let(:json_format) { Class.new { def html? = false }.new }
64
+
65
+ before do
66
+ allow(controller).to receive(:rsc_template_exists?).and_return(true)
67
+ allow(controller).to receive(:rsc_render)
68
+ end
69
+
70
+ it "calls rsc_render when format is HTML and template exists (AC#1)" do
71
+ allow(fake_request).to receive(:format).and_return(html_format)
72
+ controller.send(:default_render)
73
+ expect(controller).to have_received(:rsc_render)
74
+ end
75
+
76
+ it "calls rsc_render for RSC requests (text/x-component) even without html? (AC#1)" do
77
+ allow(fake_request).to receive(:format).and_return(json_format)
78
+ fake_request.headers["RSC-Request"] = "1"
79
+ controller.send(:default_render)
80
+ expect(controller).to have_received(:rsc_render)
81
+ end
82
+
83
+ it "does NOT call rsc_render when format is not HTML and not RSC (AC#5, AC#6 — FR26)" do
84
+ allow(fake_request).to receive(:format).and_return(json_format)
85
+ allow(controller).to receive(:rsc_render)
86
+ begin
87
+ controller.send(:default_render)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ expect(controller).not_to have_received(:rsc_render)
92
+ end
93
+
94
+ it "does NOT call rsc_render when no template exists" do
95
+ allow(controller).to receive(:rsc_template_exists?).and_return(false)
96
+ allow(fake_request).to receive(:format).and_return(html_format)
97
+ begin
98
+ controller.send(:default_render)
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ expect(controller).not_to have_received(:rsc_render)
103
+ end
104
+ end
105
+
106
+ describe "#redirect_to" do
107
+ # Build a class hierarchy so `super` inside the override can call a base implementation.
108
+ # url_for and render must be defined for verify_partial_doubles to allow stubbing them.
109
+ let(:redirect_test_class) do
110
+ base = Class.new do
111
+ def redirect_to(*)
112
+ :super_called
113
+ end
114
+
115
+ def url_for(options)
116
+ options.is_a?(String) ? options : "/"
117
+ end
118
+
119
+ def render(**_opts); end
120
+ end
121
+ Class.new(base) do
122
+ include Ruact::Controller
123
+
124
+ attr_reader :request
125
+
126
+ def initialize(req)
127
+ super()
128
+ @request = req
129
+ end
130
+ end
131
+ end
132
+
133
+ let(:rsc_ctrl) do
134
+ redirect_test_class.new(Struct.new(:headers, :host).new({ "Accept" => "text/x-component" }, "localhost"))
135
+ end
136
+ let(:html_ctrl) do
137
+ redirect_test_class.new(Struct.new(:headers, :host).new({}, "localhost"))
138
+ end
139
+
140
+ context "when RSC request with same-origin URL (AC #1, #5)" do
141
+ before { allow(rsc_ctrl).to receive(:url_for).and_return("/posts/1") }
142
+
143
+ it "calls render with a Flight redirect row (not a 302)" do
144
+ allow(rsc_ctrl).to receive(:render)
145
+ rsc_ctrl.send(:redirect_to, "/posts/1")
146
+ expect(rsc_ctrl).to have_received(:render).with(
147
+ plain: "0:{\"redirectUrl\":\"/posts/1\",\"redirectType\":\"push\"}\n",
148
+ content_type: "text/x-component"
149
+ )
150
+ end
151
+
152
+ it "redirect row matches the flight fixture (AC #5)" do
153
+ rendered_plain = nil
154
+ allow(rsc_ctrl).to receive(:render) { |opts| rendered_plain = opts[:plain] }
155
+ rsc_ctrl.send(:redirect_to, "/posts/1")
156
+ expect(rendered_plain).to match_flight_fixture("redirect_row")
157
+ end
158
+ end
159
+
160
+ context "when RSC request with external URL (AC #3)" do
161
+ before { allow(rsc_ctrl).to receive(:url_for).and_return("https://external.com/page") }
162
+
163
+ it "does NOT emit a redirect row (falls back to super)" do
164
+ allow(rsc_ctrl).to receive(:render)
165
+ rsc_ctrl.send(:redirect_to, "https://external.com/page")
166
+ expect(rsc_ctrl).not_to have_received(:render)
167
+ end
168
+ end
169
+
170
+ context "when non-RSC request (AC #4)" do
171
+ before { allow(html_ctrl).to receive(:url_for).and_return("/posts/1") }
172
+
173
+ it "does NOT emit a redirect row (falls back to super)" do
174
+ allow(html_ctrl).to receive(:render)
175
+ html_ctrl.send(:redirect_to, "/posts/1")
176
+ expect(html_ctrl).not_to have_received(:render)
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "#rsc_html_shell" do
182
+ # vite_tags requires Rails.env — stub it so we can test the shell structure.
183
+ before { allow(controller).to receive(:vite_tags).and_return("") }
184
+
185
+ let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
186
+
187
+ it "returns a string containing window.__FLIGHT_DATA" do
188
+ html = controller.send(:rsc_html_shell, payload)
189
+ expect(html).to include("__FLIGHT_DATA")
190
+ end
191
+
192
+ it "wraps the payload in an IIFE push" do
193
+ html = controller.send(:rsc_html_shell, payload)
194
+ expect(html).to include("d.push(")
195
+ end
196
+
197
+ it "contains a root div#root element" do
198
+ html = controller.send(:rsc_html_shell, payload)
199
+ expect(html).to include('<div id="root">')
200
+ end
201
+
202
+ it "escapes </script> in the payload to prevent XSS breakout" do
203
+ dangerous_payload = "0:\"</script><script>alert(1)</script>\"\n"
204
+ html = controller.send(:rsc_html_shell, dangerous_payload)
205
+ # The HTML must contain exactly ONE </script> — the real closing tag of the script block.
206
+ # If the payload's </script> leaked through, there would be more than one.
207
+ occurrences = html.scan("</script>")
208
+ count = occurrences.length
209
+ expect(count).to eq(1), "Expected 1 </script> (closing tag), found #{count}"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "socket"
7
+
8
+ # The Rails stub (including Rails.root) is provided by spec/support/rails_stub.rb.
9
+ require "ruact/doctor"
10
+
11
+ RSpec.describe Ruact::Doctor do
12
+ let(:tmpdir) { Pathname.new(Dir.mktmpdir) }
13
+
14
+ before { Rails.root = tmpdir }
15
+ after { FileUtils.rm_rf(tmpdir) }
16
+
17
+ # --- helpers ---
18
+
19
+ def make_controller(with_include: true)
20
+ dir = tmpdir.join("app", "controllers")
21
+ FileUtils.mkdir_p(dir)
22
+ content = with_include ? "include Ruact::Controller\n" : "class ApplicationController\nend\n"
23
+ File.write(dir.join("application_controller.rb"), content)
24
+ end
25
+
26
+ def make_layout(with_sentinel: true)
27
+ dir = tmpdir.join("app", "views", "layouts")
28
+ FileUtils.mkdir_p(dir)
29
+ content = with_sentinel ? "<%# ruact: root %>\n<div id=\"root\"></div>\n" : "<body></body>\n"
30
+ File.write(dir.join("application.html.erb"), content)
31
+ end
32
+
33
+ def make_manifest
34
+ dir = tmpdir.join("public")
35
+ FileUtils.mkdir_p(dir)
36
+ File.write(dir.join("react-client-manifest.json"), "{}")
37
+ end
38
+
39
+ # --- check_manifest ---
40
+
41
+ describe "#check_manifest (AC#1, #2)" do
42
+ subject(:doctor) { described_class.new }
43
+
44
+ context "when manifest file exists" do
45
+ before { make_manifest }
46
+
47
+ it "returns :pass" do
48
+ status, = doctor.send(:check_manifest)
49
+ expect(status).to eq(:pass)
50
+ end
51
+
52
+ it "message includes 'Manifest found at'" do
53
+ _, msg = doctor.send(:check_manifest)
54
+ expect(msg).to include("Manifest found at")
55
+ end
56
+ end
57
+
58
+ context "when manifest file is missing" do
59
+ it "returns :fail" do
60
+ status, = doctor.send(:check_manifest)
61
+ expect(status).to eq(:fail)
62
+ end
63
+
64
+ it "message is 'Manifest not found — run vite build'" do
65
+ _, msg = doctor.send(:check_manifest)
66
+ expect(msg).to eq("Manifest not found — run vite build")
67
+ end
68
+ end
69
+ end
70
+
71
+ # --- check_vite ---
72
+
73
+ describe "#check_vite (AC#1, #3)" do
74
+ subject(:doctor) { described_class.new }
75
+
76
+ context "when Vite is accessible" do
77
+ before { allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil)) }
78
+
79
+ it "returns :pass" do
80
+ status, = doctor.send(:check_vite)
81
+ expect(status).to eq(:pass)
82
+ end
83
+ end
84
+
85
+ context "when Vite is not accessible" do
86
+ before { allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED) }
87
+
88
+ it "returns :fail" do
89
+ status, = doctor.send(:check_vite)
90
+ expect(status).to eq(:fail)
91
+ end
92
+
93
+ it "message is 'Vite not accessible at localhost:5173 — run npm run dev'" do
94
+ _, msg = doctor.send(:check_vite)
95
+ expect(msg).to eq("Vite not accessible at localhost:5173 — run npm run dev")
96
+ end
97
+ end
98
+ end
99
+
100
+ # --- check_controller ---
101
+
102
+ describe "#check_controller (AC#1, #4)" do
103
+ subject(:doctor) { described_class.new }
104
+
105
+ context "when ApplicationController includes Ruact::Controller" do
106
+ before { make_controller(with_include: true) }
107
+
108
+ it "returns :pass" do
109
+ status, = doctor.send(:check_controller)
110
+ expect(status).to eq(:pass)
111
+ end
112
+ end
113
+
114
+ context "when Ruact::Controller is not included" do
115
+ before { make_controller(with_include: false) }
116
+
117
+ it "returns :fail" do
118
+ status, = doctor.send(:check_controller)
119
+ expect(status).to eq(:fail)
120
+ end
121
+
122
+ it "message is 'Ruact::Controller not included in ApplicationController'" do
123
+ _, msg = doctor.send(:check_controller)
124
+ expect(msg).to eq("Ruact::Controller not included in ApplicationController")
125
+ end
126
+ end
127
+ end
128
+
129
+ # --- check_layout ---
130
+
131
+ describe "#check_layout (AC#1, #5)" do
132
+ subject(:doctor) { described_class.new }
133
+
134
+ context "when layout contains the React shell sentinel" do
135
+ before { make_layout(with_sentinel: true) }
136
+
137
+ it "returns :pass" do
138
+ status, = doctor.send(:check_layout)
139
+ expect(status).to eq(:pass)
140
+ end
141
+ end
142
+
143
+ context "when React shell sentinel is absent" do
144
+ before { make_layout(with_sentinel: false) }
145
+
146
+ it "returns :fail" do
147
+ status, = doctor.send(:check_layout)
148
+ expect(status).to eq(:fail)
149
+ end
150
+
151
+ it "message is 'React shell missing from application.html.erb'" do
152
+ _, msg = doctor.send(:check_layout)
153
+ expect(msg).to eq("React shell missing from application.html.erb")
154
+ end
155
+ end
156
+ end
157
+
158
+ # --- check_streaming ---
159
+
160
+ describe "#check_streaming (AC#5)" do
161
+ subject(:doctor) { described_class.new }
162
+
163
+ after { Ruact.streaming_mode = nil }
164
+
165
+ it "always returns :pass" do
166
+ status, = doctor.send(:check_streaming)
167
+ expect(status).to eq(:pass)
168
+ end
169
+
170
+ context "when streaming_mode is :enabled (AC#1, #5)" do
171
+ before do
172
+ Ruact.streaming_mode = :enabled
173
+ stub_const("Puma", Module.new)
174
+ end
175
+
176
+ it "message includes 'enabled' and 'Puma'" do
177
+ _, msg = doctor.send(:check_streaming)
178
+ expect(msg).to include("enabled").and include("Puma")
179
+ end
180
+ end
181
+
182
+ context "when streaming_mode is :buffered with no known server (AC#3, #5)" do
183
+ before { Ruact.streaming_mode = :buffered }
184
+
185
+ it "message includes 'buffered'" do
186
+ _, msg = doctor.send(:check_streaming)
187
+ expect(msg).to include("buffered")
188
+ end
189
+ end
190
+
191
+ context "when streaming_mode is nil (not yet detected)" do
192
+ before { Ruact.streaming_mode = nil }
193
+
194
+ it "defaults to buffered in the message" do
195
+ _, msg = doctor.send(:check_streaming)
196
+ expect(msg).to include("buffered")
197
+ end
198
+ end
199
+ end
200
+
201
+ # --- run / .run ---
202
+
203
+ describe ".run / #run (AC#1, #7)" do
204
+ before do
205
+ make_manifest
206
+ make_controller(with_include: true)
207
+ make_layout(with_sentinel: true)
208
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
209
+ end
210
+
211
+ context "when all checks pass" do
212
+ it "returns true" do
213
+ expect(described_class.run).to be true
214
+ end
215
+
216
+ it "does not print the fix hint" do
217
+ expect { described_class.run }.not_to output(/rails generate/).to_stdout
218
+ end
219
+ end
220
+
221
+ context "when any check fails" do
222
+ before { allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED) }
223
+
224
+ it "returns false" do
225
+ expect(described_class.run).to be false
226
+ end
227
+
228
+ it "prints the fix hint" do
229
+ expect { described_class.run }
230
+ .to output(/Run rails generate ruact:install to fix configuration issues/).to_stdout
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe ErbPreprocessorHook do
7
+ # Minimal stand-in for ActionView::Template::Handlers::ERB:
8
+ # base implementation just returns source unchanged so we can inspect the
9
+ # transformed version that the hook passes to super.
10
+ let(:handler_class) do
11
+ klass = Class.new do
12
+ def call(_template, source)
13
+ source
14
+ end
15
+ end
16
+ klass.prepend(described_class)
17
+ klass
18
+ end
19
+
20
+ let(:handler) { handler_class.new }
21
+ let(:fake_template) { Object.new }
22
+
23
+ describe "#call" do
24
+ it "applies ErbPreprocessor.transform to source before calling super" do
25
+ source = "<LikeButton postId={1} />"
26
+ result = handler.call(fake_template, source)
27
+ expect(result).to include("__rsc_component__")
28
+ expect(result).to include('"LikeButton"')
29
+ expect(result).not_to include("<LikeButton")
30
+ end
31
+
32
+ it "passes source unchanged when no PascalCase tags present (fast-path)" do
33
+ source = "<div class=\"hello\"><p>No RSC here</p></div>"
34
+ result = handler.call(fake_template, source)
35
+ expect(result).to eq(source)
36
+ end
37
+
38
+ it "processes multiple components in a single template" do
39
+ source = "<NavBar /><LikeButton postId={1} />"
40
+ result = handler.call(fake_template, source)
41
+ expect(result).to include('"NavBar"')
42
+ expect(result).to include('"LikeButton"')
43
+ end
44
+
45
+ it "transforms Suspense tags correctly" do
46
+ source = "<Suspense fallback=\"Loading\"><PostCard /></Suspense>"
47
+ result = handler.call(fake_template, source)
48
+ expect(result).to include("__rsc_component__")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe ErbPreprocessor do
7
+ subject(:transform) { ->(source) { described_class.transform(source) } }
8
+
9
+ describe "self-closing tags" do
10
+ it "transforms a self-closing tag with no props" do
11
+ expect(transform.call("<Button />")).to eq(%(<%= __rsc_component__("Button", {}) %>))
12
+ end
13
+
14
+ it "transforms a self-closing tag with props" do
15
+ result = transform.call("<LikeButton postId={@post.id} initialCount={5} />")
16
+ expect(result).to eq(%(<%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
17
+ end
18
+ end
19
+
20
+ describe "opening tags" do
21
+ it "transforms an opening tag with props" do
22
+ result = transform.call("<Dialog open={true}>")
23
+ expect(result).to eq(%(<%= __rsc_component__("Dialog", { "open" => true }) %>))
24
+ end
25
+ end
26
+
27
+ describe "passthrough (no transformation)" do
28
+ it "does not touch lowercase HTML tags" do
29
+ source = '<div class="foo"><span>hello</span></div>'
30
+ expect(transform.call(source)).to eq(source)
31
+ end
32
+
33
+ it "does not touch ERB tags" do
34
+ source = "<%= @post.title %>"
35
+ expect(transform.call(source)).to eq(source)
36
+ end
37
+ end
38
+
39
+ describe "complex prop expressions" do
40
+ it "handles nested braces in a prop value" do
41
+ result = transform.call("<Select options={Category.all.map { |c| c.id }} />")
42
+ expect(result).to eq(%(<%= __rsc_component__("Select", { "options" => Category.all.map { |c| c.id } }) %>))
43
+ end
44
+ end
45
+
46
+ describe "error handling" do
47
+ it "raises PreprocessorError with line number and snippet for unclosed brace (AC#3)" do
48
+ source = "<LikeButton postId={@post.id />"
49
+ expect { transform.call(source) }
50
+ .to raise_error(PreprocessorError, /unclosed brace/)
51
+ expect { transform.call(source) }
52
+ .to raise_error(PreprocessorError, /line 1/)
53
+ expect { transform.call(source) }
54
+ .to raise_error(PreprocessorError, /LikeButton/)
55
+ end
56
+
57
+ it "includes the correct line number for an error on line 3" do
58
+ source = "line1\nline2\n<Bad prop={unclosed />"
59
+ expect { transform.call(source) }
60
+ .to raise_error(PreprocessorError, /line 3/)
61
+ end
62
+ end
63
+
64
+ describe "multiple components" do
65
+ it "transforms multiple components in the same string" do
66
+ source = '<Button /> and <Badge label={"hello"} />'
67
+ result = transform.call(source)
68
+ expect(result).to match(/__rsc_component__\("Button"/)
69
+ expect(result).to match(/__rsc_component__\("Badge"/)
70
+ expect(result).to match(/"hello"/)
71
+ end
72
+ end
73
+
74
+ describe "mixed content" do
75
+ it "preserves surrounding HTML while transforming components" do
76
+ source = <<~ERB
77
+ <div class="container">
78
+ <h1>Hello</h1>
79
+ <LikeButton postId={1} />
80
+ </div>
81
+ ERB
82
+ result = transform.call(source)
83
+ expect(result).to match(/<div class="container">/)
84
+ expect(result).to match(%r{<h1>Hello</h1>})
85
+ expect(result).to match(/__rsc_component__\("LikeButton"/)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe "Error classes" do
7
+ describe "Ruact::Error" do
8
+ it "is a subclass of StandardError" do
9
+ expect(Error.ancestors).to include(StandardError)
10
+ end
11
+ end
12
+
13
+ describe "Ruact::ManifestError" do
14
+ it "is a subclass of Ruact::Error" do
15
+ expect(ManifestError.ancestors).to include(Error)
16
+ end
17
+
18
+ it "can be raised and rescued as Ruact::Error" do
19
+ expect { raise ManifestError, "test" }.to raise_error(Error)
20
+ end
21
+ end
22
+
23
+ describe "Ruact::SerializationError" do
24
+ it "is a subclass of Ruact::Error" do
25
+ expect(SerializationError.ancestors).to include(Error)
26
+ end
27
+
28
+ it "can be raised and rescued as Ruact::Error" do
29
+ expect { raise SerializationError, "test" }.to raise_error(Error)
30
+ end
31
+ end
32
+
33
+ describe "Ruact::PreprocessorError" do
34
+ it "is a subclass of Ruact::Error" do
35
+ expect(PreprocessorError.ancestors).to include(Error)
36
+ end
37
+
38
+ it "can be raised and rescued as Ruact::Error" do
39
+ expect { raise PreprocessorError, "test" }.to raise_error(Error)
40
+ end
41
+ end
42
+ end
43
+ end