ruact 0.0.11 → 0.0.12

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,1549 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "tmpdir"
5
- require "fileutils"
6
- require "json"
7
- require "ruact"
8
-
9
- RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
10
- describe ".vite_plugin_path" do
11
- it "returns a string" do
12
- expect(described_class.vite_plugin_path).to be_a(String)
13
- end
14
-
15
- it "points to an existing file (NFR15 — bundled plugin present in gem)" do
16
- expect(File.exist?(described_class.vite_plugin_path)).to be true
17
- end
18
-
19
- it "points to the vite-plugin-ruact index.js" do
20
- expect(described_class.vite_plugin_path).to end_with("vite-plugin-ruact/index.js")
21
- end
22
-
23
- it "contains the plugin export function" do
24
- content = File.read(described_class.vite_plugin_path)
25
- expect(content).to include("export default function ruact")
26
- end
27
- end
28
-
29
- describe ".configure / .config" do
30
- after { described_class.instance_variable_set(:@config, nil) }
31
-
32
- it "returns a Configuration instance" do
33
- expect(described_class.config).to be_a(Ruact::Configuration)
34
- end
35
-
36
- it "yields config to configure block" do
37
- described_class.configure { |c| c.suspense_timeout = 10.0 }
38
- expect(described_class.config.suspense_timeout).to eq(10.0)
39
- end
40
-
41
- it "returns the same instance on repeated calls (singleton)" do
42
- first = described_class.config
43
- second = described_class.config
44
- expect(first).to be(second)
45
- end
46
-
47
- it "has sensible defaults" do
48
- config = described_class.config
49
- expect(config.manifest_path).to be_nil
50
- expect(config.strict_serialization).to be false
51
- expect(config.suspense_timeout).to eq(5.0)
52
- expect(config.vite_dev_server).to eq("http://localhost:5173")
53
- end
54
- end
55
-
56
- describe "generator action helpers" do
57
- # These tests exercise the core file-manipulation logic extracted from the generator
58
- # using plain Ruby + tmpdir — no Rails::Generators infrastructure required.
59
-
60
- let(:tmpdir) { Dir.mktmpdir("ruact_generator_spec") }
61
-
62
- after { FileUtils.rm_rf(tmpdir) }
63
-
64
- def write_file(relative_path, content)
65
- full = File.join(tmpdir, relative_path)
66
- FileUtils.mkdir_p(File.dirname(full))
67
- File.write(full, content)
68
- full
69
- end
70
-
71
- def read_file(relative_path)
72
- File.read(File.join(tmpdir, relative_path))
73
- end
74
-
75
- # Reproduces inject_controller_concern logic from the generator
76
- def inject_controller_concern(dest_root)
77
- controller_file = File.join(dest_root, "app/controllers/application_controller.rb")
78
- return :missing unless File.exist?(controller_file)
79
-
80
- content = File.read(controller_file)
81
- return :already_present if content.include?("Ruact::Controller")
82
-
83
- modified = content.sub(
84
- /^(class ApplicationController.*)\n/,
85
- "\\1\n include Ruact::Controller\n"
86
- )
87
- File.write(controller_file, modified)
88
- :injected
89
- end
90
-
91
- describe "ApplicationController injection (AC#1, AC#3)" do
92
- let(:controller_content) do
93
- "class ApplicationController < ActionController::Base\nend\n"
94
- end
95
-
96
- it "injects include Ruact::Controller after the class declaration" do
97
- write_file("app/controllers/application_controller.rb", controller_content)
98
- result = inject_controller_concern(tmpdir)
99
-
100
- expect(result).to eq(:injected)
101
- content = read_file("app/controllers/application_controller.rb")
102
- expect(content).to include("include Ruact::Controller")
103
- end
104
-
105
- it "returns :already_present on second run (idempotent, AC#3)" do
106
- write_file("app/controllers/application_controller.rb",
107
- "class ApplicationController < ActionController::Base\n include Ruact::Controller\nend\n")
108
-
109
- result = inject_controller_concern(tmpdir)
110
- expect(result).to eq(:already_present)
111
- end
112
-
113
- it "does not duplicate the include when run twice (AC#3)" do
114
- write_file("app/controllers/application_controller.rb", controller_content)
115
- inject_controller_concern(tmpdir)
116
- inject_controller_concern(tmpdir)
117
-
118
- content = read_file("app/controllers/application_controller.rb")
119
- occurrences = content.scan("Ruact::Controller").size
120
- expect(occurrences).to eq(1)
121
- end
122
- end
123
-
124
- describe "vite.config.js template (AC#2)" do
125
- let(:template_path) do
126
- File.expand_path(
127
- "../../lib/generators/ruact/install/templates/vite.config.js.tt",
128
- __dir__
129
- )
130
- end
131
-
132
- it "references Ruact.vite_plugin_path in the generated content" do
133
- expect(File.read(template_path)).to include("Ruact.vite_plugin_path")
134
- end
135
-
136
- it "does not reference a hardcoded npm package name" do
137
- content = File.read(template_path)
138
- expect(content).not_to include("from 'vite-plugin-ruact'")
139
- expect(content).not_to include('from "vite-plugin-ruact"')
140
- end
141
- end
142
-
143
- describe "server-functions scaffold (Story 8.0a — AC8)", :story_8_0a do
144
- # Reproduces append_gitignore_entries logic from the install generator
145
- # so the spec can be expressed without a full Rails::Generators::TestCase.
146
- let(:gitignore_entries) do
147
- [
148
- "app/javascript/.ruact/server-functions.ts",
149
- "tmp/cache/ruact/"
150
- ]
151
- end
152
-
153
- # Mirror of the real generator's append_gitignore_entries logic
154
- # (gem/lib/generators/ruact/install/install_generator.rb). Kept in sync
155
- # at line-set membership granularity — the previous substring-based
156
- # version drifted from the real generator after the Chunk 1 review's
157
- # line-set fix and the Re-run 2026-05-14 patch brings this helper
158
- # back into parity.
159
- def append_gitignore_entries(dest_root)
160
- gitignore = File.join(dest_root, ".gitignore")
161
- return :no_gitignore unless File.exist?(gitignore)
162
-
163
- existing_lines = File.read(gitignore).each_line.to_set { |line| line.chomp.strip }
164
- new_entries = gitignore_entries.reject { |e| existing_lines.include?(e) }
165
- return :already_present if new_entries.empty?
166
-
167
- File.open(gitignore, "a") do |io|
168
- io.puts
169
- io.puts "# ruact (Story 8.0a — auto-generated server-functions module)"
170
- new_entries.each { |entry| io.puts entry }
171
- end
172
- :appended
173
- end
174
-
175
- def create_gitkeep(dest_root)
176
- keep = File.join(dest_root, "app/javascript/.ruact/.gitkeep")
177
- FileUtils.mkdir_p(File.dirname(keep))
178
- return :already_present if File.exist?(keep)
179
-
180
- File.write(keep, "")
181
- :created
182
- end
183
-
184
- it "creates .ruact/.gitkeep so the directory is checkable in (Story 8.0a)" do
185
- result = create_gitkeep(tmpdir)
186
- expect(result).to eq(:created)
187
- expect(File).to exist(File.join(tmpdir, "app/javascript/.ruact/.gitkeep"))
188
- end
189
-
190
- it "appends both .gitignore entries when missing (Story 8.0a)", :aggregate_failures do
191
- write_file(".gitignore", "/tmp\n")
192
- append_gitignore_entries(tmpdir)
193
- content = read_file(".gitignore")
194
- expect(content).to include("app/javascript/.ruact/server-functions.ts")
195
- expect(content).to include("tmp/cache/ruact/")
196
- # Story 9.9 — the v1 parallel `.next` target was demolished; the
197
- # generator must no longer scaffold its gitignore entry.
198
- expect(content).not_to include("server-functions.next.ts")
199
- end
200
-
201
- it "is idempotent — running twice does not duplicate entries (Story 8.0a — pitfall #5)" do
202
- write_file(".gitignore", "/tmp\n")
203
- append_gitignore_entries(tmpdir)
204
- append_gitignore_entries(tmpdir)
205
-
206
- content = read_file(".gitignore")
207
- expect(content.scan("app/javascript/.ruact/server-functions.ts").size).to eq(1)
208
- expect(content.scan("tmp/cache/ruact/").size).to eq(1)
209
- end
210
-
211
- it "does not write to .gitignore when both entries already exist" do
212
- write_file(".gitignore", "/tmp\napp/javascript/.ruact/server-functions.ts\n" \
213
- "tmp/cache/ruact/\n")
214
- result = append_gitignore_entries(tmpdir)
215
- expect(result).to eq(:already_present)
216
- end
217
-
218
- it "still appends 'tmp/cache/ruact/' when only a longer prefix-matching path is present " \
219
- "(Re-run 2026-05-14 — line-set semantics, not substring)", :aggregate_failures do
220
- # The Chunk 1 review's line-set fix makes the real generator append
221
- # `tmp/cache/ruact/` even when the file already contains a deeper
222
- # path like `tmp/cache/ruact/some-cache.bin`. The helper used to
223
- # use substring matching, which would skip the entry — a silent
224
- # drift between helper and generator. This test pins the helper
225
- # to the same semantics.
226
- write_file(".gitignore", "/tmp\ntmp/cache/ruact/some-cache.bin\n")
227
- append_gitignore_entries(tmpdir)
228
- content = read_file(".gitignore")
229
- # Exact-line match: a new "tmp/cache/ruact/" line is appended even
230
- # though the file already contains "tmp/cache/ruact/some-cache.bin"
231
- expect(content.each_line.to_a).to include("tmp/cache/ruact/\n")
232
- expect(content).to include("app/javascript/.ruact/server-functions.ts")
233
- end
234
- end
235
-
236
- # REGRESSION (Sprint Change Proposal 2026-06-16 §4.5): a fresh `rails new`
237
- # crashed `rails generate ruact:install` on Rails 8.1 / Thor because the
238
- # generator called `destination_root.join(...)` — Thor returns
239
- # `destination_root` as a String (`File.expand_path`), which has no
240
- # path-style `#join`, so the installer raised `NoMethodError` before writing
241
- # a single file. The fix wraps every site in `Pathname(destination_root)`.
242
- #
243
- # The "generator action helpers" tests above reimplement the file logic with
244
- # `File.join`, so they never run Thor's path handling and masked the bug.
245
- # These tests invoke the REAL generator against a String destination_root, so
246
- # a revert of the Pathname fix fails loudly here.
247
- describe "real generator invocation against a String destination_root" do
248
- require "stringio"
249
- require "generators/ruact/install/install_generator"
250
-
251
- let(:app_root) { Dir.mktmpdir("ruact_install_real") }
252
-
253
- after { FileUtils.rm_rf(app_root) }
254
-
255
- def build_generator(root)
256
- Ruact::Generators::InstallGenerator.new([], {}, destination_root: root)
257
- end
258
-
259
- def silently
260
- original = $stdout
261
- $stdout = StringIO.new
262
- yield
263
- ensure
264
- $stdout = original
265
- end
266
-
267
- before do
268
- FileUtils.mkdir_p(File.join(app_root, "app/controllers"))
269
- File.write(File.join(app_root, "app/controllers/application_controller.rb"),
270
- "class ApplicationController < ActionController::Base\nend\n")
271
- FileUtils.mkdir_p(File.join(app_root, "app/views/layouts"))
272
- File.write(File.join(app_root, "app/views/layouts/application.html.erb"),
273
- "<!DOCTYPE html>\n<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
274
- File.write(File.join(app_root, ".gitignore"), "/log/*\n")
275
- end
276
-
277
- it "hands the generator a String destination_root (the exact crashing condition)" do
278
- expect(build_generator(app_root).destination_root).to be_a(String)
279
- end
280
-
281
- it "runs every path-touching action without raising and writes the files", :aggregate_failures do
282
- gen = build_generator(app_root)
283
-
284
- silently do
285
- expect do
286
- gen.inject_controller_concern
287
- gen.inject_layout_shell
288
- gen.create_components_directory
289
- gen.create_server_functions_directory
290
- gen.append_gitignore_entries
291
- gen.create_vite_config
292
- gen.create_agents_md
293
- end.not_to raise_error
294
- end
295
-
296
- expect(File.read(File.join(app_root, "app/controllers/application_controller.rb")))
297
- .to include("include Ruact::Controller")
298
- expect(File.read(File.join(app_root, "app/views/layouts/application.html.erb")))
299
- .to include('<div id="root"></div>')
300
- expect(File).to exist(File.join(app_root, "app/javascript/components/.keep"))
301
- expect(File).to exist(File.join(app_root, "app/javascript/.ruact/.gitkeep"))
302
- expect(File.read(File.join(app_root, ".gitignore")))
303
- .to include("app/javascript/.ruact/server-functions.ts")
304
- expect(File).to exist(File.join(app_root, "vite.config.js"))
305
- expect(File).to exist(File.join(app_root, "AGENTS.md"))
306
- end
307
- end
308
- end
309
-
310
- # Story 14.1 (FR101) — one-command install: `ruact:install` now runs
311
- # `npm install` by default (behind a stubbable `run_npm_install` seam),
312
- # skippable via `--skip-npm`, with an npm-on-PATH guard and outcome-aware
313
- # post-install messaging. The shell-out is ALWAYS stubbed here — no real
314
- # npm / network call is made in CI. Reuses the "real generator against a
315
- # String destination_root" pattern (instantiate + call action methods).
316
- describe "install generator — one-command npm install (Story 14.1 — FR101)", :story_14_1 do
317
- require "stringio"
318
- require "generators/ruact/install/install_generator"
319
-
320
- let(:app_root) { Dir.mktmpdir("ruact_install_npm") }
321
-
322
- after { FileUtils.rm_rf(app_root) }
323
-
324
- def build_generator(root, opts = {})
325
- Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
326
- end
327
-
328
- def silently
329
- original = $stdout
330
- $stdout = StringIO.new
331
- yield
332
- ensure
333
- $stdout = original
334
- end
335
-
336
- # Captures and returns the generator's say/say_status stdout for text asserts.
337
- def capture_stdout
338
- original = $stdout
339
- $stdout = StringIO.new
340
- yield
341
- $stdout.string
342
- ensure
343
- $stdout = original
344
- end
345
-
346
- describe "--skip-npm class option (AC#2)", :aggregate_failures do
347
- it "is registered as a boolean class_option defaulting to false" do
348
- option = Ruact::Generators::InstallGenerator.class_options[:skip_npm]
349
- expect(option).not_to be_nil
350
- expect(option.type).to eq(:boolean)
351
- expect(option.default).to be false
352
- end
353
- end
354
-
355
- describe "default run installs JS deps (AC#1, AC#6a)" do
356
- it "invokes the run_npm_install seam exactly once" do
357
- gen = build_generator(app_root)
358
- allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: true)
359
-
360
- silently { gen.install_javascript_dependencies }
361
-
362
- expect(gen).to have_received(:run_npm_install).once
363
- expect(gen.instance_variable_get(:@npm_outcome)).to eq(:installed)
364
- end
365
-
366
- it "runs the npm step AFTER the file-producing actions and BEFORE show_post_install_message",
367
- :aggregate_failures do
368
- # Thor runs public action methods in SOURCE definition order, so assert
369
- # on source line numbers (public_instance_methods order is not
370
- # guaranteed to match definition order). Story 14.2 removed
371
- # create_javascript_entry; create_vite_config is the last file-writing
372
- # action before the npm step.
373
- klass = Ruact::Generators::InstallGenerator
374
- line = ->(name) { klass.instance_method(name).source_location.last }
375
- expect(line.call(:create_vite_config)).to be < line.call(:install_javascript_dependencies)
376
- expect(line.call(:install_javascript_dependencies)).to be < line.call(:show_post_install_message)
377
- end
378
- end
379
-
380
- describe "run_npm_install seam (AC#6a)", :aggregate_failures do
381
- it "shells out `npm install` inside the destination_root" do
382
- gen = build_generator(app_root)
383
- allow(gen).to receive(:inside).and_yield
384
- allow(gen).to receive(:run)
385
-
386
- silently { gen.send(:run_npm_install) }
387
-
388
- expect(gen).to have_received(:inside).with(gen.destination_root)
389
- expect(gen).to have_received(:run).with("npm install")
390
- end
391
- end
392
-
393
- describe "--skip-npm opts out (AC#2, AC#6b)" do
394
- it "does not invoke the run_npm_install seam and emits a skip notice" do
395
- gen = build_generator(app_root, { skip_npm: true })
396
- allow(gen).to receive(:run_npm_install)
397
-
398
- output = capture_stdout { gen.install_javascript_dependencies }
399
-
400
- expect(gen).not_to have_received(:run_npm_install)
401
- expect(output).to match(/skip/i)
402
- end
403
- end
404
-
405
- describe "--pretend dry run", :aggregate_failures do
406
- # Thor's `run` returns nil under --pretend (the command is previewed, not
407
- # executed) — that must NOT be misread as an npm failure.
408
- it "treats a pretend run as a no-op, not an npm failure" do
409
- gen = build_generator(app_root, { pretend: true })
410
- allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: nil)
411
-
412
- run_output = capture_stdout { gen.install_javascript_dependencies }
413
-
414
- expect(gen.instance_variable_get(:@npm_outcome)).to eq(:pretend)
415
- expect(run_output).not_to match(/did not complete|reported a failure/i)
416
- end
417
- end
418
-
419
- describe "npm not on PATH (AC#5)", :aggregate_failures do
420
- it "does not raise, skips the seam, and prints an actionable message naming --skip-npm" do
421
- gen = build_generator(app_root)
422
- allow(gen).to receive(:npm_on_path?).and_return(false)
423
- allow(gen).to receive(:run_npm_install)
424
-
425
- output = nil
426
- expect { output = capture_stdout { gen.install_javascript_dependencies } }
427
- .not_to raise_error
428
- expect(gen).not_to have_received(:run_npm_install)
429
- expect(output).to include("--skip-npm")
430
- expect(output).to match(/npm/i)
431
- expect(output).to match(/node/i)
432
- end
433
- end
434
-
435
- describe "post-install message reflects the outcome (AC#3)", :aggregate_failures do
436
- it "states deps are installed and next step is bin/dev when npm ran" do
437
- gen = build_generator(app_root)
438
- allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: true)
439
- silently { gen.install_javascript_dependencies }
440
-
441
- output = capture_stdout { gen.show_post_install_message }
442
- expect(output).to include("are installed")
443
- expect(output).not_to include("not yet installed")
444
- expect(output).to include("bin/dev")
445
- end
446
-
447
- # AC#3 — Thor's `run` returns false on a non-zero exit (generators do not
448
- # exit_on_failure?), so a FAILED `npm install` must NOT report success.
449
- it "does NOT claim deps are installed when npm install fails (AC#3)" do
450
- gen = build_generator(app_root)
451
- allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: false)
452
-
453
- run_output = capture_stdout { gen.install_javascript_dependencies }
454
- message = capture_stdout { gen.show_post_install_message }
455
-
456
- expect(gen.instance_variable_get(:@npm_outcome)).to eq(:failed)
457
- expect(run_output).to match(/did not complete|failure/i)
458
- expect(message).not_to include("are installed")
459
- expect(message).to include("not yet installed")
460
- expect(message).to include("bin/dev")
461
- end
462
-
463
- it "tells the developer to install manually then bin/dev when skipped" do
464
- gen = build_generator(app_root, { skip_npm: true })
465
- silently { gen.install_javascript_dependencies }
466
-
467
- output = capture_stdout { gen.show_post_install_message }
468
- expect(output).to include("not yet installed")
469
- expect(output).to include("npm install")
470
- expect(output).to include("bin/dev")
471
- end
472
- end
473
- end
474
-
475
- # Story 14.2 (FR104) — the generator no longer leaks ruact plumbing into the
476
- # user's tree. After a fresh install, `app/javascript/` holds only the user's
477
- # `components/` (+ the gitignored typed `.ruact/server-functions.ts`); the
478
- # bootstrap entry is the virtual module `virtual:ruact/bootstrap`, and
479
- # `flight-client.js` / `ruact-router.js` live inside the gem.
480
- describe "install generator — hidden plumbing, no app/javascript leak (Story 14.2 — FR104)", :story_14_2 do
481
- require "stringio"
482
- require "generators/ruact/install/install_generator"
483
-
484
- let(:app_root) { Dir.mktmpdir("ruact_install_1402") }
485
-
486
- after { FileUtils.rm_rf(app_root) }
487
-
488
- def build_generator(root, opts = {})
489
- Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
490
- end
491
-
492
- def silently
493
- original = $stdout
494
- $stdout = StringIO.new
495
- yield
496
- ensure
497
- $stdout = original
498
- end
499
-
500
- before do
501
- FileUtils.mkdir_p(File.join(app_root, "app/controllers"))
502
- File.write(File.join(app_root, "app/controllers/application_controller.rb"),
503
- "class ApplicationController < ActionController::Base\nend\n")
504
- FileUtils.mkdir_p(File.join(app_root, "app/views/layouts"))
505
- File.write(File.join(app_root, "app/views/layouts/application.html.erb"),
506
- "<!DOCTYPE html>\n<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
507
- File.write(File.join(app_root, ".gitignore"), "/log/*\n")
508
- end
509
-
510
- it "does NOT define a create_javascript_entry action (no application.jsx writer)" do
511
- expect(Ruact::Generators::InstallGenerator.instance_methods).not_to include(:create_javascript_entry)
512
- end
513
-
514
- it "no longer ships the application.jsx template (its source moved into the gem runtime)" do
515
- template = File.expand_path(
516
- "../../lib/generators/ruact/install/templates/application.jsx.tt", __dir__
517
- )
518
- expect(File).not_to exist(template)
519
- end
520
-
521
- it "writes no application.jsx / flight-client.js / ruact-router.js into app/javascript", :aggregate_failures do
522
- gen = build_generator(app_root)
523
- silently do
524
- gen.create_components_directory
525
- gen.create_server_functions_directory
526
- gen.append_gitignore_entries
527
- gen.create_vite_config
528
- end
529
-
530
- expect(File).not_to exist(File.join(app_root, "app/javascript/application.jsx"))
531
- expect(File).not_to exist(File.join(app_root, "app/javascript/flight-client.js"))
532
- expect(File).not_to exist(File.join(app_root, "app/javascript/ruact-router.js"))
533
- # The user's components dir + the typed registry scaffold still exist.
534
- expect(File).to exist(File.join(app_root, "app/javascript/components/.keep"))
535
- expect(File).to exist(File.join(app_root, "app/javascript/.ruact/.gitkeep"))
536
- end
537
-
538
- it "keeps the .ruact/server-functions.ts gitignore entry unchanged (server-functions.ts stays)" do
539
- gen = build_generator(app_root)
540
- silently { gen.append_gitignore_entries }
541
- expect(File.read(File.join(app_root, ".gitignore")))
542
- .to include("app/javascript/.ruact/server-functions.ts")
543
- end
544
-
545
- describe "migration advisory for an earlier-layout app (AC7 — no half-wired state)" do
546
- def capture_stdout
547
- original = $stdout
548
- $stdout = StringIO.new
549
- yield
550
- $stdout.string
551
- ensure
552
- $stdout = original
553
- end
554
-
555
- it "prints the exact delete steps + virtual entry when stale plumbing files exist", :aggregate_failures do
556
- %w[application.jsx flight-client.js ruact-router.js].each do |f|
557
- FileUtils.mkdir_p(File.join(app_root, "app/javascript"))
558
- File.write(File.join(app_root, "app/javascript", f), "// stale\n")
559
- end
560
- gen = build_generator(app_root)
561
- output = capture_stdout { gen.advise_plumbing_migration }
562
-
563
- expect(output).to match(/earlier ruact layout detected/i)
564
- expect(output).to include("delete app/javascript/application.jsx")
565
- expect(output).to include("delete app/javascript/flight-client.js")
566
- expect(output).to include("delete app/javascript/ruact-router.js")
567
- expect(output).to include(described_class.bootstrap_virtual_id)
568
- expect(output).to include("ruact_js_assets")
569
- end
570
-
571
- it "is a no-op on a fresh install (no stale files → no notice)" do
572
- gen = build_generator(app_root)
573
- output = capture_stdout { gen.advise_plumbing_migration }
574
- expect(output).to be_empty
575
- end
576
- end
577
-
578
- describe "vite.config input targets the virtual bootstrap (AC3 — single source of truth)" do
579
- let(:template_path) do
580
- File.expand_path("../../lib/generators/ruact/install/templates/vite.config.js.tt", __dir__)
581
- end
582
-
583
- it "renders the input from Ruact.bootstrap_virtual_id, not a hardcoded application.jsx", :aggregate_failures do
584
- content = File.read(template_path)
585
- expect(content).to include("Ruact.bootstrap_virtual_id")
586
- # The `input:` line must NOT hardcode the old application.jsx entry
587
- # (a prose mention in a comment is fine — only the directive matters).
588
- expect(content).not_to match(%r{input:\s*['"]app/javascript/application\.jsx['"]})
589
- end
590
-
591
- it "the generated input equals the id the ViewHelper/prod manifest lookup uses (no drift)" do
592
- gen = build_generator(app_root)
593
- silently { gen.create_vite_config }
594
- generated = File.read(File.join(app_root, "vite.config.js"))
595
- expect(generated).to include("input: '#{described_class.bootstrap_virtual_id}'")
596
- end
597
-
598
- it "leaves an existing vite.config.js untouched when --force is not passed" do
599
- File.write(File.join(app_root, "vite.config.js"), "// hand-written\n")
600
- gen = build_generator(app_root)
601
- silently { gen.create_vite_config }
602
- expect(File.read(File.join(app_root, "vite.config.js"))).to eq("// hand-written\n")
603
- end
604
-
605
- it "actually regenerates an existing vite.config.js under --force (the message's promise)", :aggregate_failures do
606
- File.write(File.join(app_root, "vite.config.js"), "// hand-written\n")
607
- gen = build_generator(app_root, { force: true })
608
- silently { gen.create_vite_config }
609
- regenerated = File.read(File.join(app_root, "vite.config.js"))
610
- expect(regenerated).not_to include("hand-written")
611
- expect(regenerated).to include("input: '#{described_class.bootstrap_virtual_id}'")
612
- end
613
- end
614
- end
615
-
616
- # Story 14.6 (FR101, Epic 14 DoD) — `ruact:install` now emits the missing
617
- # launch pieces so the literal `bin/dev` produces a working app: a
618
- # `package.json` (so 14.1's `npm install` resolves React + Vite) and a
619
- # `Procfile.dev` + foreman `bin/dev` that boot BOTH Rails and the Vite dev
620
- # server. Each is guarded (non-clobbering, --force overwrites) and idempotent.
621
- describe "install generator — launch files: package.json + Procfile.dev + bin/dev (Story 14.6)",
622
- :story_14_6 do
623
- require "stringio"
624
- require "generators/ruact/install/install_generator"
625
-
626
- let(:app_root) { Dir.mktmpdir("ruact_install_1406") }
627
-
628
- after { FileUtils.rm_rf(app_root) }
629
-
630
- def build_generator(root, opts = {})
631
- Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
632
- end
633
-
634
- def silently
635
- original = $stdout
636
- $stdout = StringIO.new
637
- yield
638
- ensure
639
- $stdout = original
640
- end
641
-
642
- describe "create_package_json (AC#1 — JS deps to install)", :aggregate_failures do
643
- it "writes a package.json declaring React + Vite (and a `dev` script)" do
644
- gen = build_generator(app_root)
645
- silently { gen.create_package_json }
646
-
647
- path = File.join(app_root, "package.json")
648
- expect(File).to exist(path)
649
- pkg = JSON.parse(File.read(path))
650
- expect(pkg.dig("dependencies", "react")).to be_a(String)
651
- expect(pkg.dig("dependencies", "react-dom")).to be_a(String)
652
- expect(pkg.dig("devDependencies", "vite")).to be_a(String)
653
- expect(pkg.dig("devDependencies", "@vitejs/plugin-react")).to be_a(String)
654
- expect(pkg.dig("scripts", "dev")).to eq("vite")
655
- expect(pkg["type"]).to eq("module")
656
- end
657
-
658
- it "does NOT list the bundled ruact Vite plugin as an npm dependency " \
659
- "(vite.config imports it by absolute path)" do
660
- gen = build_generator(app_root)
661
- silently { gen.create_package_json }
662
- expect(File.read(File.join(app_root, "package.json"))).not_to include("vite-plugin-ruact")
663
- end
664
-
665
- it "derives a valid lowercase npm name from the app directory" do
666
- gen = build_generator(app_root)
667
- silently { gen.create_package_json }
668
- name = JSON.parse(File.read(File.join(app_root, "package.json")))["name"]
669
- expect(name).to match(/\A[a-z0-9._-]+\z/)
670
- end
671
-
672
- it "leaves an existing package.json untouched without --force (non-clobbering)" do
673
- File.write(File.join(app_root, "package.json"), %({ "name": "mine" }\n))
674
- gen = build_generator(app_root)
675
- silently { gen.create_package_json }
676
- expect(File.read(File.join(app_root, "package.json"))).to eq(%({ "name": "mine" }\n))
677
- end
678
-
679
- it "overwrites an existing package.json under --force" do
680
- File.write(File.join(app_root, "package.json"), %({ "name": "mine" }\n))
681
- gen = build_generator(app_root, { force: true })
682
- silently { gen.create_package_json }
683
- expect(File.read(File.join(app_root, "package.json"))).to include("\"vite\"")
684
- end
685
- end
686
-
687
- describe "create_launch_files (Epic DoD — bin/dev boots both processes)", :aggregate_failures do
688
- it "writes Procfile.dev with BOTH a Rails web process and a Vite process" do
689
- gen = build_generator(app_root)
690
- silently { gen.create_launch_files }
691
-
692
- procfile = File.read(File.join(app_root, "Procfile.dev"))
693
- expect(procfile).to match(/^web:.*rails server/)
694
- expect(procfile).to match(/^vite:.*npm run dev/)
695
- end
696
-
697
- it "writes an executable bin/dev that execs foreman against Procfile.dev" do
698
- gen = build_generator(app_root)
699
- silently { gen.create_launch_files }
700
-
701
- dev = File.join(app_root, "bin/dev")
702
- expect(File).to exist(dev)
703
- expect(File).to be_executable(dev)
704
- body = File.read(dev)
705
- expect(body).to include("foreman start -f Procfile.dev")
706
- expect(body).to include("gem install foreman")
707
- end
708
-
709
- # Live clean-room fix — ruact OWNS bin/dev. `rails new` (Rails 8.x) writes a
710
- # bin/dev that starts ONLY `rails server`; left in place, Vite never runs,
711
- # the client manifest is never written, and the first render 500s. So the
712
- # foreman launcher must TAKE OVER that default rather than skip it.
713
- it "takes ownership of the Rails-default bin/dev (one that only runs rails server)" do
714
- FileUtils.mkdir_p(File.join(app_root, "bin"))
715
- File.write(File.join(app_root, "bin/dev"),
716
- %(#!/usr/bin/env ruby\nexec "./bin/rails", "server", *ARGV\n))
717
-
718
- gen = build_generator(app_root)
719
- silently { gen.create_launch_files }
720
-
721
- body = File.read(File.join(app_root, "bin/dev"))
722
- expect(body).to include("foreman start -f Procfile.dev")
723
- expect(body).not_to include('exec "./bin/rails", "server"')
724
- expect(File).to be_executable(File.join(app_root, "bin/dev"))
725
- end
726
-
727
- # Idempotency (invariant 14.1) — re-running the generator must not churn:
728
- # the bin/dev we wrote already drives Procfile.dev, so the second run skips
729
- # it. (Detection is content-based: our launcher execs foreman against
730
- # Procfile.dev, so a marker comment proves the file was NOT rewritten.)
731
- it "is idempotent — a second run does not rewrite the foreman bin/dev it already wrote" do
732
- gen = build_generator(app_root)
733
- silently { gen.create_launch_files }
734
-
735
- marked = "#{File.read(File.join(app_root, 'bin/dev'))}# ruact-idempotency-marker\n"
736
- File.write(File.join(app_root, "bin/dev"), marked)
737
-
738
- gen2 = build_generator(app_root)
739
- silently { gen2.create_launch_files }
740
- expect(File.read(File.join(app_root, "bin/dev"))).to include("# ruact-idempotency-marker")
741
- end
742
-
743
- # Codex R1 — detection must be stricter than a bare `include?("Procfile.dev")`:
744
- # a bin/dev that only NAMES Procfile.dev in a comment (but still runs only
745
- # `rails server`) must be taken over, not skipped — else the 500 survives.
746
- it "takes ownership when Procfile.dev appears only in a comment (runs only rails server)" do
747
- FileUtils.mkdir_p(File.join(app_root, "bin"))
748
- File.write(File.join(app_root, "bin/dev"),
749
- %(#!/usr/bin/env ruby\n# TODO: switch to Procfile.dev + foreman one day\n) +
750
- %(exec "./bin/rails", "server", *ARGV\n))
751
-
752
- gen = build_generator(app_root)
753
- silently { gen.create_launch_files }
754
-
755
- body = File.read(File.join(app_root, "bin/dev"))
756
- expect(body).to include("foreman start -f Procfile.dev")
757
- expect(body).not_to include('exec "./bin/rails", "server"')
758
- end
759
-
760
- # Codex R2/R3 — a runner name that is only MENTIONED (not invoked as the
761
- # command) must not count as a real launcher. Each of these bin/dev files
762
- # names "foreman"/"Procfile.dev" somewhere but actually runs `rails server`,
763
- # so each must be taken over (else the original 500 survives).
764
- [
765
- ["echoed in a string",
766
- %(echo "switch to: foreman start -f Procfile.dev"\nexec ./bin/rails server\n)],
767
- ["assigned to a variable",
768
- %(MSG="switch to: foreman start -f Procfile.dev"\nexec ./bin/rails server\n)],
769
- ["named inside a command -v test",
770
- %(if command -v foreman; then echo Procfile.dev; fi\nexec ./bin/rails server\n)],
771
- ["referenced only in an inline comment on a runner diagnostic line",
772
- %(foreman --version # TODO: switch to Procfile.dev\nexec ./bin/rails server\n)],
773
- ["a runner look-alike command (not the real runner)",
774
- %(foreman-old start -f Procfile.dev\nexec ./bin/rails server\n)]
775
- ].each do |(label, contents)|
776
- it "takes ownership when a foreman command is only #{label} (still runs rails server)" do
777
- FileUtils.mkdir_p(File.join(app_root, "bin"))
778
- File.write(File.join(app_root, "bin/dev"), "#!/usr/bin/env bash\n#{contents}")
779
-
780
- gen = build_generator(app_root)
781
- silently { gen.create_launch_files }
782
-
783
- body = File.read(File.join(app_root, "bin/dev"))
784
- expect(body).to include("gem install foreman")
785
- expect(body).not_to include("exec ./bin/rails server")
786
- end
787
- end
788
-
789
- # Non-clobbering of a DELIBERATE foreman launcher — a developer's own
790
- # bin/dev that already drives Procfile.dev is ruact-compatible and is left
791
- # untouched (it boots Vite via the same Procfile.dev ruact writes).
792
- it "leaves a developer's existing foreman launcher (driving Procfile.dev) untouched" do
793
- FileUtils.mkdir_p(File.join(app_root, "bin"))
794
- File.write(File.join(app_root, "bin/dev"),
795
- "#!/usr/bin/env bash\n# my custom launcher\nexec foreman start -f Procfile.dev\n")
796
-
797
- gen = build_generator(app_root)
798
- silently { gen.create_launch_files }
799
- expect(File.read(File.join(app_root, "bin/dev"))).to include("# my custom launcher")
800
- end
801
-
802
- it "overwrites even a Procfile.dev-driving launcher under --force" do
803
- FileUtils.mkdir_p(File.join(app_root, "bin"))
804
- File.write(File.join(app_root, "bin/dev"),
805
- "#!/usr/bin/env bash\n# my custom launcher\nexec foreman start -f Procfile.dev\n")
806
-
807
- gen = build_generator(app_root, { force: true })
808
- silently { gen.create_launch_files }
809
- body = File.read(File.join(app_root, "bin/dev"))
810
- expect(body).to include("gem install foreman")
811
- expect(body).not_to include("# my custom launcher")
812
- end
813
- end
814
-
815
- describe "action ordering (npm install needs package.json on disk first)" do
816
- it "defines create_package_json BEFORE install_javascript_dependencies", :aggregate_failures do
817
- klass = Ruact::Generators::InstallGenerator
818
- line = ->(name) { klass.instance_method(name).source_location.last }
819
- expect(line.call(:create_package_json)).to be < line.call(:install_javascript_dependencies)
820
- expect(line.call(:create_launch_files)).to be < line.call(:install_javascript_dependencies)
821
- end
822
- end
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
1549
- end