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,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
RSpec.describe HtmlConverter do
|
|
7
|
+
subject(:convert) { ->(html, registry = []) { described_class.convert(html, registry) } }
|
|
8
|
+
|
|
9
|
+
describe "text nodes" do
|
|
10
|
+
it "returns a plain string for plain text" do
|
|
11
|
+
expect(convert.call("hello")).to eq("hello")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe "single DOM element" do
|
|
16
|
+
it "converts a div with class and text child" do
|
|
17
|
+
result = convert.call('<div class="box">hi</div>')
|
|
18
|
+
expect(result).to be_a(Flight::ReactElement)
|
|
19
|
+
expect(result.type).to eq("div")
|
|
20
|
+
expect(result.props["className"]).to eq("box")
|
|
21
|
+
expect(result.props["children"]).to eq("hi")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "converts `for` attribute to `htmlFor`" do
|
|
25
|
+
result = convert.call('<label for="email">Email</label>')
|
|
26
|
+
expect(result.props.keys).to include("htmlFor")
|
|
27
|
+
expect(result.props["htmlFor"]).to eq("email")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "extracts `data-react-key` into the element key and removes it from props" do
|
|
31
|
+
result = convert.call('<article data-react-key="post-1">body</article>')
|
|
32
|
+
expect(result.key).to eq("post-1")
|
|
33
|
+
expect(result.props).not_to have_key("data-react-key")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe "nested elements" do
|
|
38
|
+
it "produces a child ReactElement for a nested element" do
|
|
39
|
+
result = convert.call("<div><span>hello</span></div>")
|
|
40
|
+
expect(result.type).to eq("div")
|
|
41
|
+
child = result.props["children"]
|
|
42
|
+
expect(child).to be_a(Flight::ReactElement)
|
|
43
|
+
expect(child.type).to eq("span")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "produces an array of children for multiple child elements" do
|
|
47
|
+
result = convert.call("<ul><li>a</li><li>b</li></ul>")
|
|
48
|
+
children = result.props["children"]
|
|
49
|
+
expect(children).to be_an(Array)
|
|
50
|
+
expect(children.length).to eq(2)
|
|
51
|
+
expect(children[0].type).to eq("li")
|
|
52
|
+
expect(children[1].type).to eq("li")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe "fragment (multiple root elements)" do
|
|
57
|
+
it "returns an array for multiple sibling root elements" do
|
|
58
|
+
result = convert.call("<p>one</p><p>two</p>")
|
|
59
|
+
expect(result).to be_an(Array)
|
|
60
|
+
expect(result.length).to eq(2)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe "client component via registry" do
|
|
65
|
+
it "replaces RSC comment placeholder with the registered ReactElement" do
|
|
66
|
+
ref = Flight::ClientReference.new(module_id: "./LikeButton", export_name: "LikeButton")
|
|
67
|
+
registry = [{ token: "__RSC_0__", name: "LikeButton", ref: ref, props: { "postId" => 1 } }]
|
|
68
|
+
result = convert.call("<!-- __RSC_0__ -->", registry)
|
|
69
|
+
|
|
70
|
+
expect(result).to be_a(Flight::ReactElement)
|
|
71
|
+
expect(result.type).to eq(ref)
|
|
72
|
+
expect(result.props).to eq({ "postId" => 1 })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "wraps a client component inside a parent DOM element" do
|
|
76
|
+
ref = Flight::ClientReference.new(module_id: "./Button", export_name: "Button")
|
|
77
|
+
registry = [{ token: "__RSC_0__", name: "Button", ref: ref, props: {} }]
|
|
78
|
+
result = convert.call('<div class="wrapper"><!-- __RSC_0__ --></div>', registry)
|
|
79
|
+
|
|
80
|
+
expect(result.type).to eq("div")
|
|
81
|
+
child = result.props["children"]
|
|
82
|
+
expect(child).to be_a(Flight::ReactElement)
|
|
83
|
+
expect(child.type).to eq(ref)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe "form element value handling" do
|
|
88
|
+
it "converts text input value to defaultValue (uncontrolled)" do
|
|
89
|
+
result = convert.call('<input type="text" value="hello" />')
|
|
90
|
+
expect(result.props).to have_key("defaultValue")
|
|
91
|
+
expect(result.props["defaultValue"]).to eq("hello")
|
|
92
|
+
expect(result.props).not_to have_key("value")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "preserves value on submit inputs (controlled is correct for buttons)" do
|
|
96
|
+
result = convert.call('<input type="submit" value="Save" />')
|
|
97
|
+
expect(result.props).to have_key("value")
|
|
98
|
+
expect(result.props["value"]).to eq("Save")
|
|
99
|
+
expect(result.props).not_to have_key("defaultValue")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "preserves value on reset inputs" do
|
|
103
|
+
result = convert.call('<input type="reset" value="Clear" />')
|
|
104
|
+
expect(result.props["value"]).to eq("Clear")
|
|
105
|
+
expect(result.props).not_to have_key("defaultValue")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "converts textarea value to defaultValue" do
|
|
109
|
+
result = convert.call('<textarea value="content">content</textarea>')
|
|
110
|
+
expect(result.props["defaultValue"]).to eq("content")
|
|
111
|
+
expect(result.props).not_to have_key("value")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "converts select value to defaultValue" do
|
|
115
|
+
result = convert.call('<select value="b"><option value="a">A</option><option value="b">B</option></select>')
|
|
116
|
+
expect(result.props["defaultValue"]).to eq("b")
|
|
117
|
+
expect(result.props).not_to have_key("value")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "converts checked attribute to defaultChecked" do
|
|
121
|
+
result = convert.call('<input type="checkbox" checked="checked" />')
|
|
122
|
+
expect(result.props).to have_key("defaultChecked")
|
|
123
|
+
expect(result.props["defaultChecked"]).to eq("checked")
|
|
124
|
+
expect(result.props).not_to have_key("checked")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe "style attribute" do
|
|
129
|
+
it "converts a CSS string to a camelCase hash" do
|
|
130
|
+
result = convert.call('<h1 style="font-size:28px;font-weight:700;margin-bottom:24px">Title</h1>')
|
|
131
|
+
expect(result.props["style"]).to eq({
|
|
132
|
+
"fontSize" => "28px",
|
|
133
|
+
"fontWeight" => "700",
|
|
134
|
+
"marginBottom" => "24px"
|
|
135
|
+
})
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "handles multi-word property values without splitting" do
|
|
139
|
+
result = convert.call('<div style="max-width:640px;margin:40px auto">x</div>')
|
|
140
|
+
expect(result.props["style"]).to eq({
|
|
141
|
+
"maxWidth" => "640px",
|
|
142
|
+
"margin" => "40px auto"
|
|
143
|
+
})
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "ruact"
|
|
7
|
+
|
|
8
|
+
RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
9
|
+
describe ".vite_plugin_path" do
|
|
10
|
+
it "returns a string" do
|
|
11
|
+
expect(described_class.vite_plugin_path).to be_a(String)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "points to an existing file (NFR15 — bundled plugin present in gem)" do
|
|
15
|
+
expect(File.exist?(described_class.vite_plugin_path)).to be true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "points to the vite-plugin-ruact index.js" do
|
|
19
|
+
expect(described_class.vite_plugin_path).to end_with("vite-plugin-ruact/index.js")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "contains the plugin export function" do
|
|
23
|
+
content = File.read(described_class.vite_plugin_path)
|
|
24
|
+
expect(content).to include("export default function ruact")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe ".configure / .config" do
|
|
29
|
+
after { described_class.instance_variable_set(:@config, nil) }
|
|
30
|
+
|
|
31
|
+
it "returns a Configuration instance" do
|
|
32
|
+
expect(described_class.config).to be_a(Ruact::Configuration)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "yields config to configure block" do
|
|
36
|
+
described_class.configure { |c| c.suspense_timeout = 10.0 }
|
|
37
|
+
expect(described_class.config.suspense_timeout).to eq(10.0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "returns the same instance on repeated calls (singleton)" do
|
|
41
|
+
first = described_class.config
|
|
42
|
+
second = described_class.config
|
|
43
|
+
expect(first).to be(second)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "has sensible defaults" do
|
|
47
|
+
config = described_class.config
|
|
48
|
+
expect(config.manifest_path).to be_nil
|
|
49
|
+
expect(config.strict_serialization).to be false
|
|
50
|
+
expect(config.suspense_timeout).to eq(5.0)
|
|
51
|
+
expect(config.vite_dev_server).to eq("http://localhost:5173")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "generator action helpers" do
|
|
56
|
+
# These tests exercise the core file-manipulation logic extracted from the generator
|
|
57
|
+
# using plain Ruby + tmpdir — no Rails::Generators infrastructure required.
|
|
58
|
+
|
|
59
|
+
let(:tmpdir) { Dir.mktmpdir("ruact_generator_spec") }
|
|
60
|
+
|
|
61
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
62
|
+
|
|
63
|
+
def write_file(relative_path, content)
|
|
64
|
+
full = File.join(tmpdir, relative_path)
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
66
|
+
File.write(full, content)
|
|
67
|
+
full
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_file(relative_path)
|
|
71
|
+
File.read(File.join(tmpdir, relative_path))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reproduces inject_controller_concern logic from the generator
|
|
75
|
+
def inject_controller_concern(dest_root)
|
|
76
|
+
controller_file = File.join(dest_root, "app/controllers/application_controller.rb")
|
|
77
|
+
return :missing unless File.exist?(controller_file)
|
|
78
|
+
|
|
79
|
+
content = File.read(controller_file)
|
|
80
|
+
return :already_present if content.include?("Ruact::Controller")
|
|
81
|
+
|
|
82
|
+
modified = content.sub(
|
|
83
|
+
/^(class ApplicationController.*)\n/,
|
|
84
|
+
"\\1\n include Ruact::Controller\n"
|
|
85
|
+
)
|
|
86
|
+
File.write(controller_file, modified)
|
|
87
|
+
:injected
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reproduces inject_layout_shell logic from the generator
|
|
91
|
+
def inject_layout_shell(dest_root)
|
|
92
|
+
layout_file = File.join(dest_root, "app/views/layouts/application.html.erb")
|
|
93
|
+
return :missing unless File.exist?(layout_file)
|
|
94
|
+
|
|
95
|
+
content = File.read(layout_file)
|
|
96
|
+
return :already_present if content.include?("ruact: root")
|
|
97
|
+
|
|
98
|
+
modified = content.sub(
|
|
99
|
+
" </body>",
|
|
100
|
+
" <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
|
|
101
|
+
)
|
|
102
|
+
File.write(layout_file, modified)
|
|
103
|
+
:injected
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "ApplicationController injection (AC#1, AC#3)" do
|
|
107
|
+
let(:controller_content) do
|
|
108
|
+
"class ApplicationController < ActionController::Base\nend\n"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "injects include Ruact::Controller after the class declaration" do
|
|
112
|
+
write_file("app/controllers/application_controller.rb", controller_content)
|
|
113
|
+
result = inject_controller_concern(tmpdir)
|
|
114
|
+
|
|
115
|
+
expect(result).to eq(:injected)
|
|
116
|
+
content = read_file("app/controllers/application_controller.rb")
|
|
117
|
+
expect(content).to include("include Ruact::Controller")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "returns :already_present on second run (idempotent, AC#3)" do
|
|
121
|
+
write_file("app/controllers/application_controller.rb",
|
|
122
|
+
"class ApplicationController < ActionController::Base\n include Ruact::Controller\nend\n")
|
|
123
|
+
|
|
124
|
+
result = inject_controller_concern(tmpdir)
|
|
125
|
+
expect(result).to eq(:already_present)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "does not duplicate the include when run twice (AC#3)" do
|
|
129
|
+
write_file("app/controllers/application_controller.rb", controller_content)
|
|
130
|
+
inject_controller_concern(tmpdir)
|
|
131
|
+
inject_controller_concern(tmpdir)
|
|
132
|
+
|
|
133
|
+
content = read_file("app/controllers/application_controller.rb")
|
|
134
|
+
occurrences = content.scan("Ruact::Controller").size
|
|
135
|
+
expect(occurrences).to eq(1)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe "Layout injection (AC#1, AC#3)" do
|
|
140
|
+
let(:layout_content) do
|
|
141
|
+
<<~HTML
|
|
142
|
+
<!DOCTYPE html>
|
|
143
|
+
<html>
|
|
144
|
+
<body>
|
|
145
|
+
<%= yield %>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
HTML
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "injects the RSC root div before </body>" do
|
|
152
|
+
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
153
|
+
result = inject_layout_shell(tmpdir)
|
|
154
|
+
|
|
155
|
+
expect(result).to eq(:injected)
|
|
156
|
+
content = read_file("app/views/layouts/application.html.erb")
|
|
157
|
+
expect(content).to include('<div id="root"></div>')
|
|
158
|
+
expect(content).to include("ruact: root")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "returns :already_present on second run (idempotent, AC#3)" do
|
|
162
|
+
content_with_marker = layout_content.sub(
|
|
163
|
+
" </body>",
|
|
164
|
+
" <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
|
|
165
|
+
)
|
|
166
|
+
write_file("app/views/layouts/application.html.erb", content_with_marker)
|
|
167
|
+
|
|
168
|
+
result = inject_layout_shell(tmpdir)
|
|
169
|
+
expect(result).to eq(:already_present)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "does not duplicate the root div when run twice (AC#3)" do
|
|
173
|
+
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
174
|
+
inject_layout_shell(tmpdir)
|
|
175
|
+
inject_layout_shell(tmpdir)
|
|
176
|
+
|
|
177
|
+
content = read_file("app/views/layouts/application.html.erb")
|
|
178
|
+
occurrences = content.scan('<div id="root">').size
|
|
179
|
+
expect(occurrences).to eq(1)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "places the root div before the closing </body> tag" do
|
|
183
|
+
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
184
|
+
inject_layout_shell(tmpdir)
|
|
185
|
+
|
|
186
|
+
content = read_file("app/views/layouts/application.html.erb")
|
|
187
|
+
root_pos = content.index('<div id="root">')
|
|
188
|
+
body_pos = content.index("</body>")
|
|
189
|
+
expect(root_pos).to be < body_pos
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
describe "vite.config.js template (AC#2)" do
|
|
194
|
+
let(:template_path) do
|
|
195
|
+
File.expand_path(
|
|
196
|
+
"../../lib/generators/ruact/install/templates/vite.config.js.tt",
|
|
197
|
+
__dir__
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "references Ruact.vite_plugin_path in the generated content" do
|
|
202
|
+
expect(File.read(template_path)).to include("Ruact.vite_plugin_path")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "does not reference a hardcoded npm package name" do
|
|
206
|
+
content = File.read(template_path)
|
|
207
|
+
expect(content).not_to include("from 'vite-plugin-ruact'")
|
|
208
|
+
expect(content).not_to include('from "vite-plugin-ruact"')
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "logger"
|
|
6
|
+
require "socket"
|
|
7
|
+
|
|
8
|
+
# The Rails stub is provided by spec/support/rails_stub.rb.
|
|
9
|
+
# We explicitly require the railtie here (was skipped in ruact.rb because
|
|
10
|
+
# Rails was not defined when that file loaded).
|
|
11
|
+
require "ruact/railtie"
|
|
12
|
+
|
|
13
|
+
RSpec.describe Ruact::Railtie do
|
|
14
|
+
let(:missing_path) { Pathname.new("/nonexistent/path/react-client-manifest.json") }
|
|
15
|
+
let(:fake_logger) { instance_double(Logger, warn: nil, info: nil) }
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
Rails.logger = fake_logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe ".detect_streaming_mode! (AC#1–3)" do
|
|
22
|
+
after { Ruact.streaming_mode = nil }
|
|
23
|
+
|
|
24
|
+
context "when Puma is defined" do
|
|
25
|
+
before do
|
|
26
|
+
puma = Module.new { const_set(:Server, Class.new) }
|
|
27
|
+
stub_const("Puma", puma)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "sets streaming_mode to :enabled" do
|
|
31
|
+
described_class.detect_streaming_mode!
|
|
32
|
+
expect(Ruact.streaming_mode).to eq(:enabled)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "logs streaming: enabled (Puma detected)" do
|
|
36
|
+
described_class.detect_streaming_mode!
|
|
37
|
+
expect(fake_logger).to have_received(:info)
|
|
38
|
+
.with(a_string_including("streaming: enabled (Puma detected)"))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
context "when Unicorn is defined" do
|
|
43
|
+
before { stub_const("Unicorn", Module.new) }
|
|
44
|
+
|
|
45
|
+
it "sets streaming_mode to :buffered" do
|
|
46
|
+
described_class.detect_streaming_mode!
|
|
47
|
+
expect(Ruact.streaming_mode).to eq(:buffered)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "logs streaming: buffered (Unicorn detected)" do
|
|
51
|
+
described_class.detect_streaming_mode!
|
|
52
|
+
expect(fake_logger).to have_received(:info)
|
|
53
|
+
.with(a_string_including("streaming: buffered (Unicorn detected)"))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context "when PhusionPassenger is defined" do
|
|
58
|
+
before { stub_const("PhusionPassenger", Module.new) }
|
|
59
|
+
|
|
60
|
+
it "sets streaming_mode to :buffered" do
|
|
61
|
+
described_class.detect_streaming_mode!
|
|
62
|
+
expect(Ruact.streaming_mode).to eq(:buffered)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "logs streaming: buffered (Passenger detected)" do
|
|
66
|
+
described_class.detect_streaming_mode!
|
|
67
|
+
expect(fake_logger).to have_received(:info)
|
|
68
|
+
.with(a_string_including("streaming: buffered (Passenger detected)"))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context "when no recognized server constant is defined" do
|
|
73
|
+
it "sets streaming_mode to :buffered" do
|
|
74
|
+
described_class.detect_streaming_mode!
|
|
75
|
+
expect(Ruact.streaming_mode).to eq(:buffered)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "logs server unknown — defaulting to safe mode" do
|
|
79
|
+
described_class.detect_streaming_mode!
|
|
80
|
+
expect(fake_logger).to have_received(:info)
|
|
81
|
+
.with(a_string_including("server unknown — defaulting to safe mode"))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe ".check_manifest!" do
|
|
87
|
+
context "with missing manifest in development (AC#5, #7)" do
|
|
88
|
+
before do
|
|
89
|
+
Rails.env = ActiveSupport::StringInquirer.new("development")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "does not raise" do
|
|
93
|
+
expect { described_class.check_manifest!(missing_path) }.not_to raise_error
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "logs a [ruact] prefixed warning" do
|
|
97
|
+
described_class.check_manifest!(missing_path)
|
|
98
|
+
expect(fake_logger).to have_received(:warn)
|
|
99
|
+
.with(a_string_starting_with("[ruact]"))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "includes the manifest path in the warning" do
|
|
103
|
+
described_class.check_manifest!(missing_path)
|
|
104
|
+
expect(fake_logger).to have_received(:warn)
|
|
105
|
+
.with(a_string_including(missing_path.to_s))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context "with missing manifest in production (AC#6)" do
|
|
110
|
+
before do
|
|
111
|
+
Rails.env = ActiveSupport::StringInquirer.new("production")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "raises ManifestError" do
|
|
115
|
+
expect { described_class.check_manifest!(missing_path) }
|
|
116
|
+
.to raise_error(Ruact::ManifestError)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "error message contains 'run vite build before deploying'" do
|
|
120
|
+
expect { described_class.check_manifest!(missing_path) }
|
|
121
|
+
.to raise_error(Ruact::ManifestError, /run vite build before deploying/)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe ".check_vite!" do
|
|
127
|
+
context "when Vite is running (AC#4)" do
|
|
128
|
+
before do
|
|
129
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "does not log a warning" do
|
|
133
|
+
described_class.check_vite!
|
|
134
|
+
expect(fake_logger).not_to have_received(:warn)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
context "when Vite is not running (AC#4)" do
|
|
139
|
+
before do
|
|
140
|
+
allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "logs a [ruact] prefixed warning" do
|
|
144
|
+
described_class.check_vite!
|
|
145
|
+
expect(fake_logger).to have_received(:warn)
|
|
146
|
+
.with(a_string_starting_with("[ruact]"))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "mentions Vite and port 5173" do
|
|
150
|
+
described_class.check_vite!
|
|
151
|
+
expect(fake_logger).to have_received(:warn)
|
|
152
|
+
.with(a_string_including("localhost:5173"))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|