ruact 0.0.7 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +378 -6
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  7. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  9. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  10. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  11. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  12. data/lib/ruact/configuration.rb +73 -0
  13. data/lib/ruact/controller/document_rendering.rb +210 -0
  14. data/lib/ruact/controller.rb +14 -46
  15. data/lib/ruact/doctor.rb +102 -12
  16. data/lib/ruact/erb_preprocessor.rb +113 -0
  17. data/lib/ruact/errors.rb +16 -0
  18. data/lib/ruact/layout_source.rb +59 -0
  19. data/lib/ruact/manifest_resolver.rb +2 -2
  20. data/lib/ruact/serializable.rb +98 -6
  21. data/lib/ruact/server.rb +163 -0
  22. data/lib/ruact/server_functions/introspection.rb +81 -0
  23. data/lib/ruact/server_functions.rb +26 -4
  24. data/lib/ruact/testing/component_query.rb +113 -0
  25. data/lib/ruact/testing/flight_extractor.rb +221 -0
  26. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  27. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  28. data/lib/ruact/testing.rb +90 -0
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +10 -1
  31. data/lib/ruact.rb +1 -0
  32. data/lib/tasks/ruact.rake +55 -2
  33. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  34. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  35. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  36. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  37. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  38. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  39. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  40. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  41. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  42. data/spec/ruact/controller_request_spec.rb +203 -0
  43. data/spec/ruact/doctor_spec.rb +222 -6
  44. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  45. data/spec/ruact/install_generator_spec.rb +727 -70
  46. data/spec/ruact/layout_source_spec.rb +108 -0
  47. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  48. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  49. data/spec/ruact/serializable_spec.rb +126 -0
  50. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  51. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  52. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  53. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  54. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  55. data/spec/support/flight_wire_parser.rb +12 -126
  56. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  57. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  58. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  59. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  60. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  61. metadata +30 -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(
@@ -359,6 +289,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
359
289
  gen.create_server_functions_directory
360
290
  gen.append_gitignore_entries
361
291
  gen.create_vite_config
292
+ gen.create_agents_md
362
293
  end.not_to raise_error
363
294
  end
364
295
 
@@ -371,6 +302,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
371
302
  expect(File.read(File.join(app_root, ".gitignore")))
372
303
  .to include("app/javascript/.ruact/server-functions.ts")
373
304
  expect(File).to exist(File.join(app_root, "vite.config.js"))
305
+ expect(File).to exist(File.join(app_root, "AGENTS.md"))
374
306
  end
375
307
  end
376
308
  end
@@ -889,4 +821,729 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
889
821
  end
890
822
  end
891
823
  end
824
+
825
+ # Story 15.1 (FR105) — `ruact:install` emits an AGENTS.md teaching coding
826
+ # agents the ruact conventions, traps, and verification commands. The ruact
827
+ # content is delimited by explicit markers so re-runs are idempotent, a
828
+ # user-authored AGENTS.md is appended to (never clobbered), and --force
829
+ # refreshes ONLY the marked section. Content assertions grep stable tokens
830
+ # (commands, file paths, API names) — never full-file snapshots, since later
831
+ # stories (15.2/15.3/15.4) deliberately evolve the prose.
832
+ describe "install generator — AGENTS.md emission (Story 15.1 — FR105)", :story_15_1 do
833
+ require "stringio"
834
+ require "generators/ruact/install/install_generator"
835
+
836
+ let(:app_root) { Dir.mktmpdir("ruact_install_1501") }
837
+ let(:agents_md_path) { File.join(app_root, "AGENTS.md") }
838
+ let(:begin_marker) { "<!-- ruact:begin -->" }
839
+ let(:end_marker) { "<!-- ruact:end -->" }
840
+ let(:template_path) do
841
+ File.expand_path("../../lib/generators/ruact/install/templates/AGENTS.md.tt", __dir__)
842
+ end
843
+
844
+ after { FileUtils.rm_rf(app_root) }
845
+
846
+ def build_generator(root, opts = {})
847
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
848
+ end
849
+
850
+ def silently
851
+ original = $stdout
852
+ $stdout = StringIO.new
853
+ yield
854
+ ensure
855
+ $stdout = original
856
+ end
857
+
858
+ # Captures and returns the generator's say/say_status stdout for text asserts.
859
+ def capture_stdout
860
+ original = $stdout
861
+ $stdout = StringIO.new
862
+ yield
863
+ $stdout.string
864
+ ensure
865
+ $stdout = original
866
+ end
867
+
868
+ describe "fresh emission (AC#1)" do
869
+ it "creates AGENTS.md delimited by the ruact markers", :aggregate_failures do
870
+ silently { build_generator(app_root).create_agents_md }
871
+
872
+ expect(File).to exist(agents_md_path)
873
+ content = File.read(agents_md_path)
874
+ expect(content).to start_with(begin_marker)
875
+ expect(content.rstrip).to end_with(end_marker)
876
+ end
877
+
878
+ it "covers the model, codegen, queries and verification areas", :aggregate_failures do
879
+ silently { build_generator(app_root).create_agents_md }
880
+ content = File.read(agents_md_path)
881
+
882
+ # The verb rule (mutations = non-GET routed actions on Ruact::Server)
883
+ expect(content).to include("Ruact::Server")
884
+ expect(content).to include("non-GET")
885
+ # Route-driven codegen: ground-truth file + regenerate command
886
+ expect(content).to include("app/javascript/.ruact/server-functions.ts")
887
+ expect(content).to include("bin/rails ruact:server_functions:generate")
888
+ # Queries (reads)
889
+ expect(content).to include("Ruact::Query")
890
+ expect(content).to include("app/queries/")
891
+ expect(content).to include("ruact_queries")
892
+ expect(content).to include("useQuery")
893
+ # Verification commands (the only two that exist today)
894
+ expect(content).to include("bin/rails ruact:doctor")
895
+ # Docs pointers
896
+ expect(content).to include("https://ruact.dev")
897
+ expect(content).to include("llms.txt")
898
+ end
899
+
900
+ it "covers the five traps and the safety areas", :aggregate_failures do
901
+ silently { build_generator(app_root).create_agents_md }
902
+ content = File.read(agents_md_path)
903
+
904
+ # Trap 1 — children unsupported / self-closing only
905
+ expect(content).to include("self-closing")
906
+ expect(content).to include("children")
907
+ # Trap 2 — Ruby (not JS) inside {} props
908
+ expect(content).to include("Ruby, not JavaScript")
909
+ # Trap 3 — Accept-header dual shape (exact application/json)
910
+ expect(content).to include("Accept")
911
+ expect(content).to include("application/json")
912
+ # Trap 4 — ruact_errors fall-through
913
+ expect(content).to include("ruact_errors")
914
+ expect(content).to include("fall-through")
915
+ # Trap 5 — name derivation from routes
916
+ expect(content).to include("createPost")
917
+ expect(content).to include("ruact_function_name")
918
+ # Serialization allowlist
919
+ expect(content).to include("ruact_props")
920
+ expect(content).to include("strict_serialization")
921
+ # SGID helpers (purpose + expiry required grain)
922
+ expect(content).to include("Ruact.signed_global_id")
923
+ expect(content).to include("Ruact.locate_signed")
924
+ end
925
+
926
+ it "makes no claim before its artifact (AC#4 — content honesty)", :aggregate_failures do
927
+ silently { build_generator(app_root).create_agents_md }
928
+ content = File.read(agents_md_path)
929
+
930
+ # (The loud children `PreprocessorError` is Story 15.2's shipped
931
+ # artifact, and the `--json` introspection commands are Story 15.3's —
932
+ # both may now be claimed; see the `:story_15_2` / `:story_15_3` guards
933
+ # below that PIN each claim's presence. Story 15.4's public render
934
+ # helpers are now shipped too — see the `:story_15_4` guard below.)
935
+ #
936
+ # tsc does not run in a fresh install (no typescript devDep / tsconfig) —
937
+ # it may only appear conditionally phrased (D1).
938
+ expect(content).to match(/if your app has typescript tooling/i) if content.include?("tsc")
939
+ # Volatile strings must not be quoted: no gem version pins.
940
+ expect(content).not_to match(/\b0\.0\.\d+\b/)
941
+ end
942
+
943
+ # Story 15.2 (D2) — now that the loud children error is a shipped artifact,
944
+ # trap #1 truthfully claims it fails LOUDLY (was "fails silently" in 15.1).
945
+ it "trap #1 claims the loud PreprocessorError (Story 15.2 D2)", :aggregate_failures, :story_15_2 do
946
+ silently { build_generator(app_root).create_agents_md }
947
+ content = File.read(agents_md_path)
948
+
949
+ expect(content).to include("PreprocessorError")
950
+ expect(content).to match(/fail LOUDLY/i)
951
+ expect(content).not_to match(/fail silently/i)
952
+ end
953
+
954
+ # Story 15.3 (D2) — now that the `--json` introspection surface is a shipped
955
+ # artifact, the "Verify your work" section references it (relaxes the 15.1
956
+ # honesty guard above). This PINS the claim AND its EXPERIMENTAL framing so
957
+ # the template never presents the JSON shape as a stable contract.
958
+ it "references --json introspection as EXPERIMENTAL (15.3 D2)", :aggregate_failures, :story_15_3 do
959
+ silently { build_generator(app_root).create_agents_md }
960
+ content = File.read(agents_md_path)
961
+
962
+ expect(content).to include("ruact:routes -- --json")
963
+ expect(content).to include("ruact:doctor")
964
+ expect(content).to include("Append `-- --json`")
965
+ expect(content).to match(/EXPERIMENTAL/)
966
+ expect(content).to include("schema_version")
967
+ end
968
+
969
+ # Story 15.4 (D6) — now that the public render-assertion helpers are a
970
+ # shipped artifact (FR108), the "Verify your work" section references them.
971
+ # This PINS the claim (name + explicit require + the JSON-vs-Flight rule)
972
+ # so it stays consistent with `website/public/llms.txt`.
973
+ it "references the ruact/testing render helpers (15.4 D6)", :aggregate_failures, :story_15_4 do
974
+ silently { build_generator(app_root).create_agents_md }
975
+ content = File.read(agents_md_path)
976
+
977
+ expect(content).to include(%(require "ruact/testing"))
978
+ expect(content).to include("have_ruact_component")
979
+ expect(content).to include("JSON.parse(response.body)")
980
+ end
981
+ end
982
+
983
+ describe "idempotency (AC#2 — double-run zero diff)" do
984
+ it "leaves the file byte-identical on a second run and reports a skip", :aggregate_failures do
985
+ silently { build_generator(app_root).create_agents_md }
986
+ first = File.read(agents_md_path)
987
+
988
+ output = capture_stdout { build_generator(app_root).create_agents_md }
989
+
990
+ expect(File.read(agents_md_path)).to eq(first)
991
+ expect(output).to include("skip")
992
+ end
993
+ end
994
+
995
+ describe "append mode (AC#2 — user-authored AGENTS.md)" do
996
+ let(:user_content) { "# My app\n\nUse pnpm here. Never touch config/secrets.yml.\n" }
997
+
998
+ it "appends the marked section preserving every pre-existing user byte", :aggregate_failures do
999
+ File.write(agents_md_path, user_content)
1000
+
1001
+ silently { build_generator(app_root).create_agents_md }
1002
+ content = File.read(agents_md_path)
1003
+
1004
+ expect(content).to start_with(user_content)
1005
+ expect(content.scan(begin_marker).count).to eq(1)
1006
+ expect(content.scan(end_marker).count).to eq(1)
1007
+ expect(content).to include("\n\n#{begin_marker}")
1008
+ end
1009
+
1010
+ it "blank-line separates the section when the user file lacks a trailing newline" do
1011
+ File.write(agents_md_path, "# My app — no trailing newline")
1012
+
1013
+ silently { build_generator(app_root).create_agents_md }
1014
+
1015
+ expect(File.read(agents_md_path))
1016
+ .to include("# My app — no trailing newline\n\n#{begin_marker}")
1017
+ end
1018
+
1019
+ it "appending then re-running is idempotent (skip, zero diff)" do
1020
+ File.write(agents_md_path, user_content)
1021
+ silently { build_generator(app_root).create_agents_md }
1022
+ appended = File.read(agents_md_path)
1023
+
1024
+ silently { build_generator(app_root).create_agents_md }
1025
+
1026
+ expect(File.read(agents_md_path)).to eq(appended)
1027
+ end
1028
+ end
1029
+
1030
+ describe "--force refresh (AC#2 — marked-block-only)" do
1031
+ it "replaces ONLY the between-marker content, never user bytes outside", :aggregate_failures do
1032
+ silently { build_generator(app_root).create_agents_md }
1033
+ section = File.read(agents_md_path)
1034
+ stale = section.sub("## Five traps", "## STALE CONTENT FROM AN OLDER GEM")
1035
+ File.write(agents_md_path, "# mine, above\n\n#{stale}\n# mine, below\n")
1036
+
1037
+ silently { build_generator(app_root, { force: true }).create_agents_md }
1038
+ content = File.read(agents_md_path)
1039
+
1040
+ expect(content).to start_with("# mine, above\n")
1041
+ expect(content).to end_with("# mine, below\n")
1042
+ expect(content).to include("## Five traps")
1043
+ expect(content).not_to include("STALE CONTENT FROM AN OLDER GEM")
1044
+ expect(content.scan(begin_marker).count).to eq(1)
1045
+ end
1046
+
1047
+ it "creates the file under --force when none exists" do
1048
+ silently { build_generator(app_root, { force: true }).create_agents_md }
1049
+ expect(File.read(agents_md_path)).to include(begin_marker)
1050
+ end
1051
+ end
1052
+
1053
+ # Codex R1 — an incomplete marker state is ambiguous in BOTH directions
1054
+ # (appending would duplicate content next to a stray marker; replacing
1055
+ # would guess the range), so the only byte-safe move is warn + no-op.
1056
+ describe "broken marker pair (byte-safety)" do
1057
+ [
1058
+ ["a begin marker without its end marker",
1059
+ "# mine\n\n<!-- ruact:begin -->\ntruncated ruact section, no end marker\n"],
1060
+ ["an end marker without its begin marker",
1061
+ "# mine\n\nstray tail of a ruact section\n<!-- ruact:end -->\n"],
1062
+ ["an end marker preceding the begin marker",
1063
+ "# mine\n\n<!-- ruact:end -->\nreversed\n<!-- ruact:begin -->\n"],
1064
+ # Codex R2 — a VALID pair plus stray extra markers is still ambiguous:
1065
+ # skip/refresh would leave (or eat past) the unmatched marker.
1066
+ ["a stray end marker before a valid pair",
1067
+ "<!-- ruact:end -->\nstray\n\n<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n"],
1068
+ ["a stray begin marker after a valid pair",
1069
+ "<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n\nstray\n<!-- ruact:begin -->\n"],
1070
+ ["two complete marker pairs",
1071
+ "<!-- ruact:begin -->\none\n<!-- ruact:end -->\n\n<!-- ruact:begin -->\ntwo\n<!-- ruact:end -->\n"]
1072
+ ].each do |(label, broken)|
1073
+ it "warns and leaves the file untouched with #{label} (no --force)", :aggregate_failures do
1074
+ File.write(agents_md_path, broken)
1075
+
1076
+ output = capture_stdout { build_generator(app_root).create_agents_md }
1077
+
1078
+ expect(File.read(agents_md_path)).to eq(broken)
1079
+ expect(output).to include("warn")
1080
+ end
1081
+
1082
+ it "warns and leaves the file untouched with #{label} (--force)", :aggregate_failures do
1083
+ File.write(agents_md_path, broken)
1084
+
1085
+ output = capture_stdout { build_generator(app_root, { force: true }).create_agents_md }
1086
+
1087
+ expect(File.read(agents_md_path)).to eq(broken)
1088
+ expect(output).to include("warn")
1089
+ end
1090
+ end
1091
+ end
1092
+
1093
+ describe "action ordering + template budget" do
1094
+ it "defines create_agents_md BEFORE install_javascript_dependencies" do
1095
+ klass = Ruact::Generators::InstallGenerator
1096
+ line = ->(name) { klass.instance_method(name).source_location.last }
1097
+ expect(line.call(:create_agents_md)).to be < line.call(:install_javascript_dependencies)
1098
+ end
1099
+
1100
+ # AC#3 tripwire — the ~150-line budget is the epic's scope probe; the
1101
+ # tripwire's tolerance is 160 (the tripwire's, not the product contract's).
1102
+ it "keeps the template body at or under 160 lines" do
1103
+ expect(File.read(template_path).lines.count).to be <= 160
1104
+ end
1105
+ end
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
892
1549
  end