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