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