ruact 0.0.8 → 0.0.9

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/lib/generators/ruact/install/install_generator.rb +264 -5
  4. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  5. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  7. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  8. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  9. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  10. data/lib/ruact/configuration.rb +73 -0
  11. data/lib/ruact/controller/document_rendering.rb +210 -0
  12. data/lib/ruact/controller.rb +5 -46
  13. data/lib/ruact/doctor.rb +37 -5
  14. data/lib/ruact/layout_source.rb +59 -0
  15. data/lib/ruact/version.rb +1 -1
  16. data/lib/ruact/view_helper.rb +10 -1
  17. data/lib/ruact.rb +1 -0
  18. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  19. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  20. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  21. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  22. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  23. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  24. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  25. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  26. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  27. data/spec/ruact/controller_request_spec.rb +203 -0
  28. data/spec/ruact/doctor_spec.rb +81 -6
  29. data/spec/ruact/install_generator_spec.rb +442 -70
  30. data/spec/ruact/layout_source_spec.rb +108 -0
  31. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  32. metadata +18 -4
@@ -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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact"
5
+
6
+ # The single definition of "is this layout migrated?", shared by the runtime and
7
+ # by `ruact:install`. Every example here is a shape that fooled an earlier,
8
+ # looser check — a NAME where only a CALL counts, or a substring where only an
9
+ # attribute counts.
10
+ RSpec.describe Ruact::LayoutSource do
11
+ describe ".wired?" do
12
+ it "accepts a plain output tag" do
13
+ expect(described_class.wired?("<%= ruact_js_assets %>")).to be true
14
+ end
15
+
16
+ it "accepts the raw-output form and tight whitespace" do
17
+ expect(described_class.wired?("<%==ruact_js_assets%>")).to be true
18
+ end
19
+
20
+ it "accepts a call with an argument" do
21
+ expect(described_class.wired?("<%= ruact_js_assets(payload) %>")).to be true
22
+ end
23
+
24
+ # Round-1 finding: a mention in prose read as wired.
25
+ it "rejects a mention inside an ERB comment" do
26
+ expect(described_class.wired?("<%# remember to add ruact_js_assets here %>")).to be false
27
+ end
28
+
29
+ # Round-2 finding, and the sharper version of the same mistake: a genuinely
30
+ # COMMENTED-OUT call. ERB comments do not nest, so this emits nothing — but
31
+ # a regex looking only for `<%=` sees a call and reports the layout ready,
32
+ # which sends the runtime off to render an unmigrated layout.
33
+ it "rejects a commented-out call" do
34
+ expect(described_class.wired?("<%# <%= ruact_js_assets %> %>")).to be false
35
+ end
36
+
37
+ it "rejects a multi-line comment that wraps a call" do
38
+ source = <<~ERB
39
+ <%#
40
+ disabled for now:
41
+ <%= ruact_js_assets %>
42
+ %>
43
+ ERB
44
+ expect(described_class.wired?(source)).to be false
45
+ end
46
+
47
+ it "still sees a real call that follows a comment mentioning it" do
48
+ expect(described_class.wired?("<%# ruact_js_assets %>\n<%= ruact_js_assets %>")).to be true
49
+ end
50
+
51
+ # Round-3 finding: ERB's trim-mode comment (`<%-# ... -%>`) is a comment in
52
+ # Erubi too — verified by compiling it — but the stripper only knew `<%#`,
53
+ # so a call disabled this way read as wired.
54
+ it "rejects a call disabled with a trim-mode comment" do
55
+ expect(described_class.wired?("<%-# <%= ruact_js_assets %> -%>")).to be false
56
+ end
57
+
58
+ # Round-3 finding, the other direction: forbidding `%` before the name to
59
+ # avoid crossing tag boundaries also rejected legitimate calls. A false
60
+ # "unwired" is safer than a false "wired", but it is still a wrong answer —
61
+ # the app would silently keep ruact's CSS-less shell.
62
+ it "accepts a call whose expression contains a percent sign" do
63
+ expect(described_class.wired?(%(<%= raw("100%") + ruact_js_assets %>))).to be true
64
+ expect(described_class.wired?("<%= ruact_js_assets if 50 % 2 == 0 %>")).to be true
65
+ end
66
+
67
+ it "still refuses to match across a tag boundary" do
68
+ expect(described_class.wired?("<%= something %> then a bare ruact_js_assets mention")).to be false
69
+ end
70
+
71
+ it "rejects a layout that never names it" do
72
+ expect(described_class.wired?("<html><body><%= yield %></body></html>")).to be false
73
+ end
74
+ end
75
+
76
+ describe ".root?" do
77
+ it "accepts the emitted form" do
78
+ expect(described_class.root?(%(<div id="root"></div>))).to be true
79
+ end
80
+
81
+ it "accepts single quotes, extra attributes and unquoted values" do
82
+ expect(described_class.root?(%(<div id='root'></div>))).to be true
83
+ expect(described_class.root?(%(<div class="a" id="root" data-x="1"></div>))).to be true
84
+ expect(described_class.root?(%(<div id=root></div>))).to be true
85
+ end
86
+
87
+ # Round-2 finding: `data-id="root"` is not a mount point, and a document
88
+ # carrying one but no real root gives React nothing to mount into.
89
+ it "rejects a look-alike attribute" do
90
+ expect(described_class.root?(%(<body data-id="root"></body>))).to be false
91
+ end
92
+
93
+ it "rejects a different id that merely starts with root" do
94
+ expect(described_class.root?(%(<div id="rootish"></div>))).to be false
95
+ end
96
+ end
97
+
98
+ # The anchor `ruact:install` injects after.
99
+ describe "::ROOT_ELEMENT" do
100
+ it "anchors on a whole empty root div" do
101
+ expect(described_class::ROOT_ELEMENT).to match(%(<div id="root"></div>))
102
+ end
103
+
104
+ it "does not anchor on a look-alike attribute" do
105
+ expect(described_class::ROOT_ELEMENT).not_to match(%(<div data-id="root"></div>))
106
+ end
107
+ end
108
+ end
@@ -1136,6 +1136,20 @@ RSpec.describe Ruact::Generators::ScaffoldGenerator, :story_10_1 do # rubocop:di
1136
1136
  expect(minimal).to eq(%w[button input label badge table alert-dialog dropdown-menu])
1137
1137
  end
1138
1138
 
1139
+ # `ruact:install --shadcn` runs before any resource exists, so it prints
1140
+ # the SUPERSET constant instead of this per-resource list. If a template
1141
+ # ever imports a primitive the constant does not carry, the printed
1142
+ # `npx shadcn add` line would be short one component and the scaffold
1143
+ # would fail its own pre-flight — so pin the relationship.
1144
+ it "keeps the per-resource list a subset of the superset install prints" do
1145
+ superset = described_class::ShadcnPreflight::ALL_SHADCN_COMPONENTS
1146
+ richest = build(%w[Post title:string body:text published:boolean author:references])
1147
+ .required_shadcn_components
1148
+
1149
+ expect(richest).to all(be_in(superset))
1150
+ expect(superset).to match_array(richest)
1151
+ end
1152
+
1139
1153
  it "maps each component to its app/javascript/components/ui/<name>.tsx file" do
1140
1154
  gen = build(default_args)
1141
1155
  expect(gen.shadcn_component_file("table").to_s)