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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/LICENSE.txt +21 -0
- data/README.md +124 -18
- data/SECURITY.md +1 -1
- data/lib/generators/ruact/install/install_generator.rb +264 -5
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +5 -46
- data/lib/ruact/doctor.rb +37 -5
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/spec/fixtures/readme/children-error.html.erb +3 -0
- data/spec/fixtures/readme/children-error.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/readme_demo_message_spec.rb +67 -0
- data/spec/readme_spec.rb +282 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +81 -6
- data/spec/ruact/install_generator_spec.rb +442 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- metadata +30 -5
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
135
|
-
before
|
|
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
|
-
|
|
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 "
|
|
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
|