ruact 0.0.8 → 0.0.10

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/LICENSE.txt +21 -0
  4. data/README.md +124 -18
  5. data/SECURITY.md +1 -1
  6. data/lib/generators/ruact/install/install_generator.rb +264 -5
  7. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  8. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  9. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  10. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  11. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  13. data/lib/ruact/configuration.rb +73 -0
  14. data/lib/ruact/controller/document_rendering.rb +210 -0
  15. data/lib/ruact/controller.rb +5 -46
  16. data/lib/ruact/doctor.rb +37 -5
  17. data/lib/ruact/layout_source.rb +59 -0
  18. data/lib/ruact/version.rb +1 -1
  19. data/lib/ruact/view_helper.rb +10 -1
  20. data/lib/ruact.rb +1 -0
  21. data/spec/fixtures/readme/children-error.html.erb +3 -0
  22. data/spec/fixtures/readme/children-error.txt +1 -0
  23. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  24. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  25. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  26. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  27. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  28. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  29. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  30. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  31. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  32. data/spec/readme_demo_message_spec.rb +67 -0
  33. data/spec/readme_spec.rb +282 -0
  34. data/spec/ruact/controller_request_spec.rb +203 -0
  35. data/spec/ruact/doctor_spec.rb +81 -6
  36. data/spec/ruact/install_generator_spec.rb +442 -70
  37. data/spec/ruact/layout_source_spec.rb +108 -0
  38. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  39. metadata +30 -5
@@ -23,10 +23,20 @@ RSpec.describe Ruact::Doctor do
23
23
  File.write(dir.join("application_controller.rb"), content)
24
24
  end
25
25
 
26
- def make_layout(with_sentinel: true)
26
+ # `with_assets` is the second half of a migrated layout: the React root alone
27
+ # leaves ruact on its built-in (CSS-less) shell, so the two markers are
28
+ # independent inputs, not one sentinel.
29
+ def make_layout(with_sentinel: true, with_assets: true)
27
30
  dir = tmpdir.join("app", "views", "layouts")
28
31
  FileUtils.mkdir_p(dir)
29
- content = with_sentinel ? "<%# ruact: root %>\n<div id=\"root\"></div>\n" : "<body></body>\n"
32
+ content =
33
+ if !with_sentinel
34
+ "<body></body>\n"
35
+ elsif with_assets
36
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%= ruact_js_assets %>\n"
37
+ else
38
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n"
39
+ end
30
40
  File.write(dir.join("application.html.erb"), content)
31
41
  end
32
42
 
@@ -131,8 +141,11 @@ RSpec.describe Ruact::Doctor do
131
141
  describe "#check_layout (AC#1, #5)" do
132
142
  subject(:doctor) { described_class.new }
133
143
 
134
- context "when layout contains the React shell sentinel" do
135
- before { make_layout(with_sentinel: true) }
144
+ context "when the layout is wired and the app opted in" do
145
+ before do
146
+ make_layout(with_sentinel: true, with_assets: true)
147
+ Ruact.configure { |c| c.layout = true }
148
+ end
136
149
 
137
150
  it "returns :pass" do
138
151
  status, = doctor.send(:check_layout)
@@ -140,7 +153,60 @@ RSpec.describe Ruact::Doctor do
140
153
  end
141
154
  end
142
155
 
143
- context "when React shell sentinel is absent" do
156
+ # Both halves are silent when wrong, and they are DIFFERENT fixes — "add one
157
+ # line to the layout" versus "flip one setting" — so they report separately.
158
+ context "when the layout is wired but Ruact.config.layout is false" do
159
+ before do
160
+ make_layout(with_sentinel: true, with_assets: true)
161
+ Ruact.configure { |c| c.layout = false }
162
+ end
163
+
164
+ it "returns :warn naming the setting, not the layout" do
165
+ status, message, remediation = doctor.send(:check_layout)
166
+
167
+ expect(status).to eq(:warn)
168
+ expect(message).to include("config.layout is false")
169
+ expect(remediation).to include("config.layout = true")
170
+ end
171
+
172
+ it "does not fail the doctor run" do
173
+ expect(described_class::SUCCESS_STATUSES).to include(:warn)
174
+ end
175
+ end
176
+
177
+ context "when the layout has the React root but never calls ruact_js_assets" do
178
+ before do
179
+ make_layout(with_sentinel: true, with_assets: false)
180
+ Ruact.configure { |c| c.layout = true }
181
+ end
182
+
183
+ it "fails and names the helper to add" do
184
+ status, _message, remediation = doctor.send(:check_layout)
185
+
186
+ expect(status).to eq(:fail)
187
+ expect(remediation).to include("ruact_js_assets")
188
+ end
189
+ end
190
+
191
+ # The drift this shares with the runtime and the generator: a MENTION of the
192
+ # helper in a comment is not a call, and reporting :pass on one sent a
193
+ # developer looking anywhere but at the real problem.
194
+ context "when the layout only mentions the helper in a comment" do
195
+ before do
196
+ dir = tmpdir.join("app", "views", "layouts")
197
+ FileUtils.mkdir_p(dir)
198
+ File.write(dir.join("application.html.erb"),
199
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%# TODO: add ruact_js_assets %>\n")
200
+ Ruact.configure { |c| c.layout = true }
201
+ end
202
+
203
+ it "does not report it as wired" do
204
+ status, = doctor.send(:check_layout)
205
+ expect(status).to eq(:fail)
206
+ end
207
+ end
208
+
209
+ context "when the layout has neither the root nor the helper" do
144
210
  before { make_layout(with_sentinel: false) }
145
211
 
146
212
  it "returns :fail" do
@@ -148,8 +214,17 @@ RSpec.describe Ruact::Doctor do
148
214
  expect(status).to eq(:fail)
149
215
  end
150
216
 
151
- it "message is 'React shell missing from application.html.erb'" do
217
+ it "names both halves, since either could be the missing one" do
152
218
  _, msg = doctor.send(:check_layout)
219
+ expect(msg).to include("React root").and include("ruact_js_assets")
220
+ end
221
+ end
222
+
223
+ context "when there is no application layout file at all" do
224
+ it "reports the file, not its contents" do
225
+ status, msg = doctor.send(:check_layout)
226
+
227
+ expect(status).to eq(:fail)
153
228
  expect(msg).to eq("React shell missing from application.html.erb")
154
229
  end
155
230
  end
@@ -88,22 +88,6 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
88
88
  :injected
89
89
  end
90
90
 
91
- # Reproduces inject_layout_shell logic from the generator
92
- def inject_layout_shell(dest_root)
93
- layout_file = File.join(dest_root, "app/views/layouts/application.html.erb")
94
- return :missing unless File.exist?(layout_file)
95
-
96
- content = File.read(layout_file)
97
- return :already_present if content.include?("ruact: root")
98
-
99
- modified = content.sub(
100
- " </body>",
101
- " <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
102
- )
103
- File.write(layout_file, modified)
104
- :injected
105
- end
106
-
107
91
  describe "ApplicationController injection (AC#1, AC#3)" do
108
92
  let(:controller_content) do
109
93
  "class ApplicationController < ActionController::Base\nend\n"
@@ -137,60 +121,6 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
137
121
  end
138
122
  end
139
123
 
140
- describe "Layout injection (AC#1, AC#3)" do
141
- let(:layout_content) do
142
- <<~HTML
143
- <!DOCTYPE html>
144
- <html>
145
- <body>
146
- <%= yield %>
147
- </body>
148
- </html>
149
- HTML
150
- end
151
-
152
- it "injects the RSC root div before </body>" do
153
- write_file("app/views/layouts/application.html.erb", layout_content)
154
- result = inject_layout_shell(tmpdir)
155
-
156
- expect(result).to eq(:injected)
157
- content = read_file("app/views/layouts/application.html.erb")
158
- expect(content).to include('<div id="root"></div>')
159
- expect(content).to include("ruact: root")
160
- end
161
-
162
- it "returns :already_present on second run (idempotent, AC#3)" do
163
- content_with_marker = layout_content.sub(
164
- " </body>",
165
- " <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
166
- )
167
- write_file("app/views/layouts/application.html.erb", content_with_marker)
168
-
169
- result = inject_layout_shell(tmpdir)
170
- expect(result).to eq(:already_present)
171
- end
172
-
173
- it "does not duplicate the root div when run twice (AC#3)" do
174
- write_file("app/views/layouts/application.html.erb", layout_content)
175
- inject_layout_shell(tmpdir)
176
- inject_layout_shell(tmpdir)
177
-
178
- content = read_file("app/views/layouts/application.html.erb")
179
- occurrences = content.scan('<div id="root">').size
180
- expect(occurrences).to eq(1)
181
- end
182
-
183
- it "places the root div before the closing </body> tag" do
184
- write_file("app/views/layouts/application.html.erb", layout_content)
185
- inject_layout_shell(tmpdir)
186
-
187
- content = read_file("app/views/layouts/application.html.erb")
188
- root_pos = content.index('<div id="root">')
189
- body_pos = content.index("</body>")
190
- expect(root_pos).to be < body_pos
191
- end
192
- end
193
-
194
124
  describe "vite.config.js template (AC#2)" do
195
125
  let(:template_path) do
196
126
  File.expand_path(
@@ -1174,4 +1104,446 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
1174
1104
  end
1175
1105
  end
1176
1106
  end
1107
+
1108
+ # The `--shadcn` prerequisites. The gap these close is concrete: shadcn's CLI
1109
+ # aborts on a ruact app with "No Tailwind CSS configuration found" and "Could
1110
+ # not find valid path aliases", because a ruact app ships neither Tailwind nor
1111
+ # a tsconfig.json. Without them `ruact:scaffold --shadcn` emitted components
1112
+ # whose classes resolved to nothing — styled markup with no styles.
1113
+ describe "install generator — --shadcn prerequisites" do
1114
+ require "stringio"
1115
+ require "generators/ruact/install/install_generator"
1116
+
1117
+ let(:app_root) { Dir.mktmpdir("ruact_install_shadcn") }
1118
+
1119
+ after { FileUtils.rm_rf(app_root) }
1120
+
1121
+ def build_generator(root, opts = {})
1122
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
1123
+ end
1124
+
1125
+ def silently
1126
+ original = $stdout
1127
+ $stdout = StringIO.new
1128
+ yield
1129
+ ensure
1130
+ $stdout = original
1131
+ end
1132
+
1133
+ def read(relative)
1134
+ File.read(File.join(app_root, relative))
1135
+ end
1136
+
1137
+ describe "with --shadcn", :aggregate_failures do
1138
+ let(:generator) { build_generator(app_root, shadcn: true) }
1139
+
1140
+ it "writes the Tailwind entry shadcn points components.json at" do
1141
+ silently { generator.create_shadcn_prerequisites }
1142
+
1143
+ expect(File).to exist(File.join(app_root, "app/javascript/styles/globals.css"))
1144
+ expect(read("app/javascript/styles/globals.css")).to include('@import "tailwindcss"')
1145
+ end
1146
+
1147
+ it "writes the tsconfig import alias shadcn probes for" do
1148
+ silently { generator.create_shadcn_prerequisites }
1149
+
1150
+ tsconfig = JSON.parse(read("tsconfig.json"))
1151
+ expect(tsconfig.dig("compilerOptions", "paths", "@/*")).to eq(["app/javascript/*"])
1152
+ end
1153
+
1154
+ it "creates the builds directory Propshaft serves the compiled stylesheet from" do
1155
+ silently { generator.create_shadcn_prerequisites }
1156
+
1157
+ expect(File).to exist(File.join(app_root, "app/assets/builds/.keep"))
1158
+ end
1159
+
1160
+ it "declares Tailwind in package.json with a build:css script" do
1161
+ silently { generator.create_package_json }
1162
+
1163
+ pkg = JSON.parse(read("package.json"))
1164
+ expect(pkg.dig("devDependencies", "tailwindcss")).to be_a(String)
1165
+ expect(pkg.dig("devDependencies", "@tailwindcss/cli")).to be_a(String)
1166
+ expect(pkg.dig("devDependencies", "tw-animate-css")).to be_a(String)
1167
+ expect(pkg.dig("scripts", "build:css")).to include("app/assets/builds/tailwind.css")
1168
+ end
1169
+
1170
+ it "adds a css watch process so bin/dev rebuilds the stylesheet" do
1171
+ silently { generator.create_launch_files }
1172
+
1173
+ expect(read("Procfile.dev")).to match(/^css: .*tailwind\.css --watch$/)
1174
+ end
1175
+
1176
+ it "gitignores the compiled stylesheet (a build artifact of globals.css)" do
1177
+ File.write(File.join(app_root, ".gitignore"), "/log/*\n")
1178
+ silently { generator.append_gitignore_entries }
1179
+
1180
+ expect(read(".gitignore")).to include("app/assets/builds/tailwind.css")
1181
+ end
1182
+
1183
+ # The one thing a reader cannot guess: current shadcn defaults to Base UI,
1184
+ # but the components the scaffold generates import Radix primitives.
1185
+ it "prints the two shadcn commands, pinning --base radix" do
1186
+ expect { generator.send(:show_shadcn_next_steps) }
1187
+ .to output(/npx shadcn@latest init --base radix/).to_stdout
1188
+ end
1189
+
1190
+ it "prints the complete add-list, so the scaffold pre-flight cannot fail on a gap" do
1191
+ expect { generator.send(:show_shadcn_next_steps) }
1192
+ .to output(/add button input textarea switch select label badge table alert-dialog dropdown-menu/)
1193
+ .to_stdout
1194
+ end
1195
+ end
1196
+
1197
+ # Codex review finding: the layout-migration branch was pinned only by the
1198
+ # REPRODUCTION helper above, which cannot see the real generator's anchor
1199
+ # regex. Invoking the actual generator caught that a layout written with
1200
+ # single quotes matched nothing while the generator still printed a success
1201
+ # message. These specs drive `InstallGenerator#inject_layout_shell` itself.
1202
+ # Thor registers every PUBLIC instance method of a generator as a command and
1203
+ # runs it in declaration order. A helper left public therefore executes on
1204
+ # its own, out of context — this has bitten three times in this branch: a
1205
+ # message printed twice, a `--shadcn`-only notice firing on the default
1206
+ # path, and a helper taking a required argument being invoked with none.
1207
+ # Pin the command list so the next one is caught here rather than in an app.
1208
+ describe "Thor command surface" do
1209
+ it "registers only the install steps, never the private helpers" do
1210
+ expect(Ruact::Generators::InstallGenerator.commands.keys).to contain_exactly(
1211
+ "create_initializer",
1212
+ "inject_controller_concern",
1213
+ "inject_layout_shell",
1214
+ "create_shadcn_prerequisites",
1215
+ "create_components_directory",
1216
+ "create_server_functions_directory",
1217
+ "append_gitignore_entries",
1218
+ "prime_server_functions_codegen",
1219
+ "create_vite_config",
1220
+ "create_package_json",
1221
+ "create_launch_files",
1222
+ "advise_plumbing_migration",
1223
+ "create_agents_md",
1224
+ "install_javascript_dependencies",
1225
+ "show_post_install_message"
1226
+ )
1227
+ end
1228
+
1229
+ it "has no command requiring arguments (Thor invokes them with none)" do
1230
+ requiring_args = Ruact::Generators::InstallGenerator.commands.keys.reject do |name|
1231
+ Ruact::Generators::InstallGenerator.instance_method(name).arity.zero?
1232
+ end
1233
+
1234
+ expect(requiring_args).to be_empty
1235
+ end
1236
+ end
1237
+
1238
+ # Migrating an existing app is the path that matters most here, and Thor's
1239
+ # `template` handles it badly: it raises an overwrite CONFLICT, offering a
1240
+ # choice between losing every setting the app already had and ending up
1241
+ # half-migrated (layout edited, `config.layout` still off) in silence.
1242
+ describe "create_initializer on an app that already has one", :aggregate_failures do
1243
+ def write_initializer(body)
1244
+ path = File.join(app_root, "config/initializers/ruact.rb")
1245
+ FileUtils.mkdir_p(File.dirname(path))
1246
+ File.write(path, body)
1247
+ path
1248
+ end
1249
+
1250
+ it "adds config.layout without touching the app's existing settings" do
1251
+ path = write_initializer(<<~RUBY)
1252
+ Ruact.configure do |config|
1253
+ config.strict_serialization = true
1254
+ config.max_upload_bytes = 25 * 1024 * 1024
1255
+ end
1256
+ RUBY
1257
+
1258
+ silently { build_generator(app_root).create_initializer }
1259
+
1260
+ result = File.read(path)
1261
+ expect(result).to include("config.layout = true")
1262
+ expect(result).to include("config.strict_serialization = true")
1263
+ expect(result).to include("config.max_upload_bytes = 25 * 1024 * 1024")
1264
+ end
1265
+
1266
+ # `config.layout = false` is a deliberate choice (keep the built-in
1267
+ # shell). Re-running install must not quietly reverse it.
1268
+ it "leaves an explicit opt-out alone" do
1269
+ path = write_initializer("Ruact.configure do |config|\n config.layout = false\nend\n")
1270
+
1271
+ silently { build_generator(app_root).create_initializer }
1272
+
1273
+ expect(File.read(path)).to include("config.layout = false")
1274
+ expect(File.read(path)).not_to include("config.layout = true")
1275
+ end
1276
+
1277
+ it "is idempotent" do
1278
+ path = write_initializer("Ruact.configure do |config|\nend\n")
1279
+
1280
+ silently { build_generator(app_root).create_initializer }
1281
+ silently { build_generator(app_root).create_initializer }
1282
+
1283
+ expect(File.read(path).scan("config.layout").size).to eq(1)
1284
+ end
1285
+
1286
+ # Never guess at an initializer we do not recognise — say what to add,
1287
+ # because the silent outcome is an app whose CSS never reaches the page.
1288
+ it "tells the user what to add when it cannot find the configure block" do
1289
+ write_initializer("# hand-rolled\nRuact.config\n")
1290
+
1291
+ output = capture_generator_output { build_generator(app_root).create_initializer }
1292
+
1293
+ expect(output).to include("could not find the Ruact.configure block")
1294
+ expect(output).to include("config.layout = true")
1295
+ end
1296
+
1297
+ def capture_generator_output
1298
+ original = $stdout
1299
+ $stdout = StringIO.new
1300
+ yield
1301
+ $stdout.string
1302
+ ensure
1303
+ $stdout = original
1304
+ end
1305
+ end
1306
+
1307
+ describe "inject_layout_shell — the REAL generator", :aggregate_failures do
1308
+ def write_layout(body)
1309
+ path = File.join(app_root, "app/views/layouts/application.html.erb")
1310
+ FileUtils.mkdir_p(File.dirname(path))
1311
+ File.write(path, body)
1312
+ path
1313
+ end
1314
+
1315
+ def layout_after_run(body)
1316
+ path = write_layout(body)
1317
+ silently { build_generator(app_root).inject_layout_shell }
1318
+ File.read(path)
1319
+ end
1320
+
1321
+ # The fresh-install path. Previously covered only by a REPRODUCTION of the
1322
+ # generator's logic, which twice drifted from the generator itself and hid
1323
+ # real defects; the reproduction is gone and these drive the real thing.
1324
+ it "adds both the root div and the asset call to a layout that has neither" do
1325
+ path = write_layout(<<~HTML)
1326
+ <html>
1327
+ <body>
1328
+ <%= yield %>
1329
+ </body>
1330
+ </html>
1331
+ HTML
1332
+
1333
+ silently { build_generator(app_root).inject_layout_shell }
1334
+
1335
+ result = File.read(path)
1336
+ expect(result).to include(%(<div id="root"></div>))
1337
+ expect(result).to include("<%= ruact_js_assets %>")
1338
+ expect(result).to include("ruact: root")
1339
+ end
1340
+
1341
+ it "places them inside the body, before the closing tag" do
1342
+ path = write_layout("<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
1343
+
1344
+ silently { build_generator(app_root).inject_layout_shell }
1345
+
1346
+ result = File.read(path)
1347
+ expect(result.index(%(<div id="root">))).to be < result.index("</body>")
1348
+ expect(result.index(%(<div id="root">))).to be < result.index("ruact_js_assets")
1349
+ end
1350
+
1351
+ it "is a no-op on a layout that is already migrated" do
1352
+ path = write_layout(<<~HTML)
1353
+ <html>
1354
+ <body>
1355
+ <%# ruact: root %>
1356
+ <div id="root"></div>
1357
+ <%= ruact_js_assets %>
1358
+ </body>
1359
+ </html>
1360
+ HTML
1361
+ before = File.read(path)
1362
+
1363
+ silently { build_generator(app_root).inject_layout_shell }
1364
+
1365
+ expect(File.read(path)).to eq(before)
1366
+ end
1367
+
1368
+ it "migrates a layout whose root div uses single quotes" do
1369
+ result = layout_after_run(<<~HTML)
1370
+ <html>
1371
+ <body>
1372
+ <%# ruact: root %>
1373
+ <div id='root'></div>
1374
+ </body>
1375
+ </html>
1376
+ HTML
1377
+
1378
+ expect(result).to include("<%= ruact_js_assets %>")
1379
+ end
1380
+
1381
+ it "migrates a layout whose root div carries extra attributes" do
1382
+ result = layout_after_run(<<~HTML)
1383
+ <html>
1384
+ <body>
1385
+ <%# ruact: root %>
1386
+ <div class="app" id="root" data-turbo="false"></div>
1387
+ </body>
1388
+ </html>
1389
+ HTML
1390
+
1391
+ expect(result).to include("<%= ruact_js_assets %>")
1392
+ end
1393
+
1394
+ it "migrates a layout with the marker and the div on one line" do
1395
+ result = layout_after_run(
1396
+ %(<html><body><%# ruact: root %><div id="root"></div></body></html>\n)
1397
+ )
1398
+
1399
+ expect(result).to include("<%= ruact_js_assets %>")
1400
+ end
1401
+
1402
+ it "is idempotent — a second run adds nothing" do
1403
+ path = write_layout(<<~HTML)
1404
+ <html>
1405
+ <body>
1406
+ <%# ruact: root %>
1407
+ <div id="root"></div>
1408
+ </body>
1409
+ </html>
1410
+ HTML
1411
+ silently { build_generator(app_root).inject_layout_shell }
1412
+ silently { build_generator(app_root).inject_layout_shell }
1413
+
1414
+ expect(File.read(path).scan("ruact_js_assets").size).to eq(1)
1415
+ end
1416
+
1417
+ # Round-2 finding: the anchor matched any attribute ENDING in `id`, so a
1418
+ # layout with `data-id="root"` and no real mount point got the helper
1419
+ # injected and a success message. It now reads as "no root present" and
1420
+ # the generator writes a proper root + helper instead of warning — the
1421
+ # look-alike div is left alone, being none of its business.
1422
+ it "does not mistake a look-alike attribute for the React root" do
1423
+ path = write_layout(<<~HTML)
1424
+ <html>
1425
+ <body>
1426
+ <%# ruact: root %>
1427
+ <div data-id="root"></div>
1428
+ </body>
1429
+ </html>
1430
+ HTML
1431
+
1432
+ silently { build_generator(app_root).inject_layout_shell }
1433
+
1434
+ result = File.read(path)
1435
+ expect(result).to match(%r{<div id="root"></div>})
1436
+ expect(result).to include("<%= ruact_js_assets %>")
1437
+ end
1438
+
1439
+ # The mirror of the legacy migration: the call is there, the mount target
1440
+ # is not. Injecting the whole block would duplicate the helper.
1441
+ it "adds only the missing root when the layout already calls the helper" do
1442
+ path = write_layout(<<~HTML)
1443
+ <html>
1444
+ <body>
1445
+ <%= yield %>
1446
+ <%= ruact_js_assets %>
1447
+ </body>
1448
+ </html>
1449
+ HTML
1450
+
1451
+ silently { build_generator(app_root).inject_layout_shell }
1452
+
1453
+ result = File.read(path)
1454
+ expect(result).to match(%r{<div id="root"></div>})
1455
+ expect(result.scan("ruact_js_assets").size).to eq(1)
1456
+ expect(result.index(%(<div id="root">))).to be < result.index("ruact_js_assets")
1457
+ end
1458
+
1459
+ # A root that is NOT an empty paired div (a spinner placeholder, say) has
1460
+ # nothing safe to inject after, so the generator says so instead of
1461
+ # guessing where the call belongs.
1462
+ it "warns when the root div is not the empty form it knows how to anchor on" do
1463
+ write_layout(<<~HTML)
1464
+ <html>
1465
+ <body>
1466
+ <%# ruact: root %>
1467
+ <div id="root">loading…</div>
1468
+ </body>
1469
+ </html>
1470
+ HTML
1471
+
1472
+ output = capture_generator_output { build_generator(app_root).inject_layout_shell }
1473
+
1474
+ expect(output).to include("could not locate the React root div")
1475
+ end
1476
+
1477
+ # Round-2 finding: the "already migrated, skip" guard was a substring
1478
+ # match, so a TODO note about the helper made the generator skip an app
1479
+ # that was never migrated.
1480
+ it "does not treat a TODO comment naming the helper as already migrated" do
1481
+ path = write_layout(<<~HTML)
1482
+ <html>
1483
+ <body>
1484
+ <%# TODO: add ruact_js_assets %>
1485
+ <%# ruact: root %>
1486
+ <div id="root"></div>
1487
+ </body>
1488
+ </html>
1489
+ HTML
1490
+
1491
+ silently { build_generator(app_root).inject_layout_shell }
1492
+
1493
+ expect(File.read(path)).to include("<%= ruact_js_assets %>")
1494
+ end
1495
+
1496
+ # Silence here is the dangerous outcome: the app keeps rendering through
1497
+ # ruact's CSS-less shell and nothing ever says why.
1498
+ it "says so LOUDLY when it cannot find the root div, instead of claiming success" do
1499
+ write_layout(<<~HTML)
1500
+ <html>
1501
+ <body>
1502
+ <%# ruact: root %>
1503
+ <span id="root"></span>
1504
+ </body>
1505
+ </html>
1506
+ HTML
1507
+
1508
+ output = capture_generator_output { build_generator(app_root).inject_layout_shell }
1509
+
1510
+ expect(output).to include("could not locate the React root div")
1511
+ expect(output).not_to include("added ruact_js_assets")
1512
+ end
1513
+
1514
+ def capture_generator_output
1515
+ original = $stdout
1516
+ $stdout = StringIO.new
1517
+ yield
1518
+ $stdout.string
1519
+ ensure
1520
+ $stdout = original
1521
+ end
1522
+ end
1523
+
1524
+ describe "without --shadcn (the default path is untouched)", :aggregate_failures do
1525
+ let(:generator) { build_generator(app_root) }
1526
+
1527
+ it "writes no Tailwind entry and no tsconfig" do
1528
+ silently { generator.create_shadcn_prerequisites }
1529
+
1530
+ expect(File).not_to exist(File.join(app_root, "app/javascript/styles/globals.css"))
1531
+ expect(File).not_to exist(File.join(app_root, "tsconfig.json"))
1532
+ end
1533
+
1534
+ it "leaves package.json free of Tailwind" do
1535
+ silently { generator.create_package_json }
1536
+
1537
+ pkg = JSON.parse(read("package.json"))
1538
+ expect(pkg["devDependencies"].keys).not_to include("tailwindcss", "@tailwindcss/cli")
1539
+ expect(pkg["scripts"]).not_to have_key("build:css")
1540
+ end
1541
+
1542
+ it "leaves Procfile.dev at the two processes it always had" do
1543
+ silently { generator.create_launch_files }
1544
+
1545
+ expect(read("Procfile.dev")).not_to include("css:")
1546
+ end
1547
+ end
1548
+ end
1177
1549
  end