ruact 0.0.10 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +123 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +16 -6
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -1
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- 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
|