ruact 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +378 -6
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  7. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  9. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  10. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  11. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  12. data/lib/ruact/configuration.rb +73 -0
  13. data/lib/ruact/controller/document_rendering.rb +210 -0
  14. data/lib/ruact/controller.rb +14 -46
  15. data/lib/ruact/doctor.rb +102 -12
  16. data/lib/ruact/erb_preprocessor.rb +113 -0
  17. data/lib/ruact/errors.rb +16 -0
  18. data/lib/ruact/layout_source.rb +59 -0
  19. data/lib/ruact/manifest_resolver.rb +2 -2
  20. data/lib/ruact/serializable.rb +98 -6
  21. data/lib/ruact/server.rb +163 -0
  22. data/lib/ruact/server_functions/introspection.rb +81 -0
  23. data/lib/ruact/server_functions.rb +26 -4
  24. data/lib/ruact/testing/component_query.rb +113 -0
  25. data/lib/ruact/testing/flight_extractor.rb +221 -0
  26. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  27. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  28. data/lib/ruact/testing.rb +90 -0
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +10 -1
  31. data/lib/ruact.rb +1 -0
  32. data/lib/tasks/ruact.rake +55 -2
  33. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  34. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  35. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  36. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  37. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  38. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  39. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  40. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  41. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  42. data/spec/ruact/controller_request_spec.rb +203 -0
  43. data/spec/ruact/doctor_spec.rb +222 -6
  44. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  45. data/spec/ruact/install_generator_spec.rb +727 -70
  46. data/spec/ruact/layout_source_spec.rb +108 -0
  47. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  48. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  49. data/spec/ruact/serializable_spec.rb +126 -0
  50. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  51. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  52. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  53. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  54. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  55. data/spec/support/flight_wire_parser.rb +12 -126
  56. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  57. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  58. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  59. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  60. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  61. metadata +30 -4
@@ -0,0 +1,15 @@
1
+ <%# A layout that calls `ruact_js_assets` but never got the React root div.
2
+ The payload and the bootstrap both ship, so an assets-only readiness check
3
+ accepts it — and the browser boots React with nothing to mount into,
4
+ producing a silently blank page. %>
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <link rel="stylesheet" href="/host-app.css" />
9
+ <title>Host App Title</title>
10
+ </head>
11
+ <body>
12
+ <%= yield %>
13
+ <%= ruact_js_assets %>
14
+ </body>
15
+ </html>
@@ -0,0 +1,17 @@
1
+ <%# A MIGRATED host layout: the app owns the document. The stylesheet link is
2
+ the whole point — it is the thing ruact's built-in shell has no slot for,
3
+ so a spec asserting it appears in the response is asserting that the host
4
+ app's CSS actually reaches a ruact page. %>
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <link rel="stylesheet" href="/host-app.css" />
9
+ <title>Host App Title</title>
10
+ </head>
11
+ <body>
12
+ <%= yield %>
13
+ <%# ruact: root %>
14
+ <div id="root"></div>
15
+ <%= ruact_js_assets %>
16
+ </body>
17
+ </html>
@@ -115,6 +115,14 @@ module ControllerRequestSpecSupport
115
115
  get "/errors-demo/new", to: "controller_request_spec_support/errors_demo#new"
116
116
  post "/errors-demo/create", to: "controller_request_spec_support/errors_demo#create"
117
117
  post "/errors-demo/create_valid", to: "controller_request_spec_support/errors_demo#create_valid"
118
+ # The layout owns the document — a page rendered through a migrated
119
+ # host layout, and one through a layout that never calls
120
+ # `ruact_js_assets`.
121
+ get "/layout-demo/show", to: "controller_request_spec_support/layout_demo#show"
122
+ get "/unwired-layout-demo/show", to: "controller_request_spec_support/unwired_layout_demo#show"
123
+ get "/exploding-layout-demo/show", to: "controller_request_spec_support/exploding_layout_demo#show"
124
+ get "/rootless-layout-demo/show", to: "controller_request_spec_support/rootless_layout_demo#show"
125
+ get "/ghost-layout-demo/show", to: "controller_request_spec_support/ghost_layout_demo#show"
118
126
  end
119
127
  end
120
128
  end
@@ -186,6 +194,73 @@ module ControllerRequestSpecSupport
186
194
 
187
195
  def show; end
188
196
  end
197
+
198
+ # The layout owns the document. Both controllers declare a NAMED layout
199
+ # instead of relying on `layouts/application`, so adding them cannot change
200
+ # what every other controller in this file renders (the rest have no
201
+ # resolvable layout and must keep getting ruact's built-in shell).
202
+ class LayoutDemoController < ActionController::Base
203
+ include Ruact::Controller
204
+
205
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
206
+ layout "ruact_host"
207
+
208
+ def show
209
+ ruact_render
210
+ end
211
+ end
212
+
213
+ # An unmigrated layout that RAISES if rendered (it reads an ivar a ruact
214
+ # action never sets). With the default config ruact must never execute it.
215
+ class ExplodingLayoutDemoController < ActionController::Base
216
+ include Ruact::Controller
217
+
218
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
219
+ layout "exploding_host"
220
+
221
+ def show
222
+ ruact_render
223
+ end
224
+ end
225
+
226
+ # Declares a layout that does not exist. `_default_layout` hands back the
227
+ # String path WITHOUT checking, so "not nil" read as resolvable and the render
228
+ # raised MissingTemplate instead of degrading.
229
+ class GhostLayoutDemoController < ActionController::Base
230
+ include Ruact::Controller
231
+
232
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
233
+ layout "no_such_host"
234
+
235
+ def show
236
+ ruact_render
237
+ end
238
+ end
239
+
240
+ # A layout that calls `ruact_js_assets` but has no root div to mount into.
241
+ class RootlessLayoutDemoController < ActionController::Base
242
+ include Ruact::Controller
243
+
244
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
245
+ layout "rootless_host"
246
+
247
+ def show
248
+ ruact_render
249
+ end
250
+ end
251
+
252
+ # Same page, but through a layout that never calls `ruact_js_assets` — the
253
+ # state every app installed before the layout owned the document is in.
254
+ class UnwiredLayoutDemoController < ActionController::Base
255
+ include Ruact::Controller
256
+
257
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
258
+ layout "bare_host"
259
+
260
+ def show
261
+ ruact_render
262
+ end
263
+ end
189
264
  end
190
265
 
191
266
  # Story 10.0 — write the implicit-render template at file-load time (before the
@@ -251,6 +326,134 @@ module Ruact # rubocop:disable Style/OneClassPerFile
251
326
  end
252
327
  end
253
328
 
329
+ # The bug this closes: a ruact page could not carry ANY of the host app's
330
+ # CSS. `ruact_render` rendered the view with `layout: false` and then wrapped
331
+ # the payload in a hardcoded shell whose `<head>` has no stylesheet slot, so
332
+ # `stylesheet_link_tag` in the app's layout never reached the browser. The
333
+ # generated `--shadcn` scaffold was therefore unstyled by construction, and
334
+ # Epic 12 (`ruact_meta` → tags in `<head>`) had no surface to write into.
335
+ #
336
+ # The opt-in is EXPLICIT (`Ruact.config.layout`). ruact used to infer
337
+ # readiness from the layout itself; three review rounds each found another
338
+ # template shape that fooled the inference, so the question is no longer
339
+ # asked. These examples pin both halves: nothing happens without the config,
340
+ # and everything happens with it.
341
+ describe "the layout owns the document" do
342
+ def configure_layout(value)
343
+ Ruact.configure do |c|
344
+ c.manifest_path = ControllerRequestSpecSupport.manifest_path
345
+ c.layout = value
346
+ end
347
+ end
348
+
349
+ # The whole backward-compatibility story, and it is now structural rather
350
+ # than argued: with the default config ruact never looks at, resolves, or
351
+ # renders a layout at all.
352
+ context "with the default configuration (layout = false)" do
353
+ it "renders the built-in shell, exactly as before the layout path existed" do
354
+ get "/layout-demo/show"
355
+
356
+ expect(last_response.status).to eq(200)
357
+ expect(last_response.body).to include("Rails RSC")
358
+ expect(last_response.body).not_to include("host-app.css")
359
+ expect(last_response.body).to include("DemoButton")
360
+ end
361
+
362
+ # This layout raises if it is rendered (it reads an ivar a ruact action
363
+ # never sets) — the shape of a real pre-migration app. Nothing may
364
+ # execute it.
365
+ it "never executes an unmigrated layout, so a working page cannot become a 500" do
366
+ get "/exploding-layout-demo/show"
367
+
368
+ expect(last_response.status).to(eq(200),
369
+ "expected the layout NOT to be rendered; got " \
370
+ "#{last_response.status} body=#{last_response.body[0, 300]}")
371
+ expect(last_response.body).to include("Rails RSC")
372
+ end
373
+ end
374
+
375
+ context "when the app opts in (layout = true)" do
376
+ before { configure_layout(true) }
377
+
378
+ it "puts the host app's stylesheet on a ruact page (the CSS could not arrive before)" do
379
+ get "/layout-demo/show"
380
+
381
+ expect(last_response.status).to(eq(200),
382
+ "expected 200, got #{last_response.status} " \
383
+ "body=#{last_response.body[0, 400]}")
384
+ expect(last_response.body).to include('<link rel="stylesheet" href="/host-app.css" />')
385
+ end
386
+
387
+ it "keeps the host layout's own <title> instead of ruact's placeholder" do
388
+ get "/layout-demo/show"
389
+
390
+ expect(last_response.body).to include("<title>Host App Title</title>")
391
+ expect(last_response.body).not_to include("Rails RSC")
392
+ end
393
+
394
+ it "still ships the Flight payload and the component, through the layout's ruact_js_assets" do
395
+ get "/layout-demo/show"
396
+
397
+ expect(last_response.body).to include("__FLIGHT_DATA")
398
+ expect(last_response.body).to include("DemoButton")
399
+ end
400
+
401
+ it "does not apply the layout to a Flight request (the wire shape is unchanged)" do
402
+ get "/layout-demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
403
+
404
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
405
+ expect(last_response.body).not_to include("host-app.css")
406
+ expect(last_response.body).to include("DemoButton")
407
+ end
408
+
409
+ # Opting in and pointing ruact at a layout that cannot mount the app is
410
+ # a configuration error, and silence would cost a blank page.
411
+ it "fails loudly when the opted-in layout never calls ruact_js_assets" do
412
+ expect { get "/unwired-layout-demo/show" }
413
+ .to raise_error(Ruact::Error, /ruact_js_assets/)
414
+ end
415
+
416
+ it "fails loudly when the opted-in layout has no root div to mount into" do
417
+ expect { get "/rootless-layout-demo/show" }
418
+ .to raise_error(Ruact::Error, /root/)
419
+ end
420
+
421
+ # A controller that declares a layout which does not exist: Rails'
422
+ # `_default_layout` returns the String path without checking it, so
423
+ # "resolvable" has to mean "the template is actually there".
424
+ it "degrades to the shell when a declared layout does not exist" do
425
+ get "/ghost-layout-demo/show"
426
+
427
+ expect(last_response.status).to(eq(200),
428
+ "expected a degrade, got #{last_response.status} " \
429
+ "body=#{last_response.body[0, 300]}")
430
+ expect(last_response.body).to include("Rails RSC")
431
+ expect(last_response.body).to include("DemoButton")
432
+ end
433
+
434
+ # `layout false` on one controller inside an app that opted in globally
435
+ # is a normal Rails pattern, not a mistake — it must degrade, not raise.
436
+ it "degrades to the shell for a controller with no resolvable layout" do
437
+ get "/demo/show"
438
+
439
+ expect(last_response.status).to eq(200)
440
+ expect(last_response.body).to include("Rails RSC")
441
+ expect(last_response.body).to include("DemoButton")
442
+ end
443
+ end
444
+
445
+ context "when a named layout is configured" do
446
+ before { configure_layout("ruact_host") }
447
+
448
+ it "renders every ruact page through that layout" do
449
+ get "/demo/show"
450
+
451
+ expect(last_response.body).to include("host-app.css")
452
+ expect(last_response.body).to include("DemoButton")
453
+ end
454
+ end
455
+ end
456
+
254
457
  describe "regression guard: render context reaches ViewHelper" do
255
458
  it "ViewHelper#__ruact_component__ does NOT raise the outside-flow error" do
256
459
  # If the handoff regresses, the request returns 500 with this exact
@@ -23,10 +23,20 @@ RSpec.describe Ruact::Doctor do
23
23
  File.write(dir.join("application_controller.rb"), content)
24
24
  end
25
25
 
26
- def make_layout(with_sentinel: true)
26
+ # `with_assets` is the second half of a migrated layout: the React root alone
27
+ # leaves ruact on its built-in (CSS-less) shell, so the two markers are
28
+ # independent inputs, not one sentinel.
29
+ def make_layout(with_sentinel: true, with_assets: true)
27
30
  dir = tmpdir.join("app", "views", "layouts")
28
31
  FileUtils.mkdir_p(dir)
29
- content = with_sentinel ? "<%# ruact: root %>\n<div id=\"root\"></div>\n" : "<body></body>\n"
32
+ content =
33
+ if !with_sentinel
34
+ "<body></body>\n"
35
+ elsif with_assets
36
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%= ruact_js_assets %>\n"
37
+ else
38
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n"
39
+ end
30
40
  File.write(dir.join("application.html.erb"), content)
31
41
  end
32
42
 
@@ -131,8 +141,11 @@ RSpec.describe Ruact::Doctor do
131
141
  describe "#check_layout (AC#1, #5)" do
132
142
  subject(:doctor) { described_class.new }
133
143
 
134
- context "when layout contains the React shell sentinel" do
135
- before { make_layout(with_sentinel: true) }
144
+ context "when the layout is wired and the app opted in" do
145
+ before do
146
+ make_layout(with_sentinel: true, with_assets: true)
147
+ Ruact.configure { |c| c.layout = true }
148
+ end
136
149
 
137
150
  it "returns :pass" do
138
151
  status, = doctor.send(:check_layout)
@@ -140,7 +153,60 @@ RSpec.describe Ruact::Doctor do
140
153
  end
141
154
  end
142
155
 
143
- context "when React shell sentinel is absent" do
156
+ # Both halves are silent when wrong, and they are DIFFERENT fixes — "add one
157
+ # line to the layout" versus "flip one setting" — so they report separately.
158
+ context "when the layout is wired but Ruact.config.layout is false" do
159
+ before do
160
+ make_layout(with_sentinel: true, with_assets: true)
161
+ Ruact.configure { |c| c.layout = false }
162
+ end
163
+
164
+ it "returns :warn naming the setting, not the layout" do
165
+ status, message, remediation = doctor.send(:check_layout)
166
+
167
+ expect(status).to eq(:warn)
168
+ expect(message).to include("config.layout is false")
169
+ expect(remediation).to include("config.layout = true")
170
+ end
171
+
172
+ it "does not fail the doctor run" do
173
+ expect(described_class::SUCCESS_STATUSES).to include(:warn)
174
+ end
175
+ end
176
+
177
+ context "when the layout has the React root but never calls ruact_js_assets" do
178
+ before do
179
+ make_layout(with_sentinel: true, with_assets: false)
180
+ Ruact.configure { |c| c.layout = true }
181
+ end
182
+
183
+ it "fails and names the helper to add" do
184
+ status, _message, remediation = doctor.send(:check_layout)
185
+
186
+ expect(status).to eq(:fail)
187
+ expect(remediation).to include("ruact_js_assets")
188
+ end
189
+ end
190
+
191
+ # The drift this shares with the runtime and the generator: a MENTION of the
192
+ # helper in a comment is not a call, and reporting :pass on one sent a
193
+ # developer looking anywhere but at the real problem.
194
+ context "when the layout only mentions the helper in a comment" do
195
+ before do
196
+ dir = tmpdir.join("app", "views", "layouts")
197
+ FileUtils.mkdir_p(dir)
198
+ File.write(dir.join("application.html.erb"),
199
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%# TODO: add ruact_js_assets %>\n")
200
+ Ruact.configure { |c| c.layout = true }
201
+ end
202
+
203
+ it "does not report it as wired" do
204
+ status, = doctor.send(:check_layout)
205
+ expect(status).to eq(:fail)
206
+ end
207
+ end
208
+
209
+ context "when the layout has neither the root nor the helper" do
144
210
  before { make_layout(with_sentinel: false) }
145
211
 
146
212
  it "returns :fail" do
@@ -148,8 +214,17 @@ RSpec.describe Ruact::Doctor do
148
214
  expect(status).to eq(:fail)
149
215
  end
150
216
 
151
- it "message is 'React shell missing from application.html.erb'" do
217
+ it "names both halves, since either could be the missing one" do
152
218
  _, msg = doctor.send(:check_layout)
219
+ expect(msg).to include("React root").and include("ruact_js_assets")
220
+ end
221
+ end
222
+
223
+ context "when there is no application layout file at all" do
224
+ it "reports the file, not its contents" do
225
+ status, msg = doctor.send(:check_layout)
226
+
227
+ expect(status).to eq(:fail)
153
228
  expect(msg).to eq("React shell missing from application.html.erb")
154
229
  end
155
230
  end
@@ -511,6 +586,141 @@ RSpec.describe Ruact::Doctor do
511
586
  end
512
587
  end
513
588
 
589
+ # --- JSON introspection (Story 15.3, FR107, AC1 + AC3 + AC4) ---
590
+
591
+ describe "#as_json (machine-readable report)", :story_15_3 do
592
+ subject(:doctor) { described_class.new }
593
+
594
+ context "when all checks pass" do
595
+ before do
596
+ make_manifest
597
+ make_controller(with_include: true)
598
+ make_layout(with_sentinel: true)
599
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
600
+ end
601
+
602
+ it "returns a document that round-trips through JSON.parse", :aggregate_failures do
603
+ report = doctor.as_json
604
+ reparsed = JSON.parse(JSON.generate(report))
605
+ expect(reparsed).to eq(report)
606
+ end
607
+
608
+ it "gates the document with the EXPERIMENTAL schema_version (0)" do
609
+ expect(doctor.as_json["schema_version"]).to eq(0)
610
+ expect(doctor.as_json["schema_version"]).to eq(Ruact::Doctor::SCHEMA_VERSION)
611
+ end
612
+
613
+ it "carries every check with name/status/message/remediation keys", :aggregate_failures do
614
+ checks = doctor.as_json["checks"]
615
+ expect(checks.map { |c| c["name"] }).to eq(described_class::CHECKS.map(&:to_s))
616
+ checks.each do |check|
617
+ expect(check.keys).to contain_exactly("name", "status", "message", "remediation")
618
+ expect(check["status"]).to be_a(String)
619
+ expect(check["message"]).to be_a(String)
620
+ end
621
+ end
622
+
623
+ it "reports top-level status 'pass' and exits-zero semantics" do
624
+ expect(doctor.as_json["status"]).to eq("pass")
625
+ end
626
+
627
+ it "emits NO prose to stdout (JSON mode is the document only)" do
628
+ expect { doctor.as_json }.not_to output.to_stdout
629
+ end
630
+ end
631
+
632
+ context "when a check fails" do
633
+ before do
634
+ make_controller(with_include: true)
635
+ make_layout(with_sentinel: true)
636
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
637
+ # manifest is missing → check_manifest fails
638
+ end
639
+
640
+ it "reports top-level status 'fail' (non-zero exit semantics)" do
641
+ expect(doctor.as_json["status"]).to eq("fail")
642
+ end
643
+
644
+ it "carries the separate machine-readable remediation for the failing check" do
645
+ manifest = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }
646
+ expect(manifest["status"]).to eq("fail")
647
+ expect(manifest["remediation"]).to eq("Run vite build (or bin/dev) to generate the client manifest.")
648
+ end
649
+ end
650
+
651
+ context "when a check warns (Rack::Deflater mounted)" do
652
+ before do
653
+ make_manifest
654
+ make_controller(with_include: true)
655
+ make_layout(with_sentinel: true)
656
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
657
+ app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
658
+ allow(Rails).to receive(:application).and_return(app)
659
+ end
660
+
661
+ it "keeps status 'pass' — a :warn does not fail — but exposes the warn check" do
662
+ report = doctor.as_json
663
+ expect(report["status"]).to eq("pass")
664
+ flight = report["checks"].find { |c| c["name"] == "flight_middleware" }
665
+ expect(flight["status"]).to eq("warn")
666
+ expect(flight["remediation"]).to include("Exclude text/x-component from compression")
667
+ end
668
+ end
669
+
670
+ it "keeps the message byte-identical to the human tuple (remediation is separate)" do
671
+ _status, human_message = doctor.send(:check_manifest)
672
+ json_message = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }["message"]
673
+ expect(json_message).to eq(human_message)
674
+ end
675
+ end
676
+
677
+ describe "#results / #passed? (shared compute, Story 15.3)", :story_15_3 do
678
+ subject(:doctor) { described_class.new }
679
+
680
+ before do
681
+ make_manifest
682
+ make_controller(with_include: true)
683
+ make_layout(with_sentinel: true)
684
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
685
+ end
686
+
687
+ it "returns one tuple per check, index-aligned with CHECKS" do
688
+ expect(doctor.results.length).to eq(described_class::CHECKS.length)
689
+ end
690
+
691
+ it "passed? is true when all checks pass/warn" do
692
+ expect(doctor.passed?).to be true
693
+ end
694
+
695
+ it "runs each check exactly once (as_json does not double-run check_vite's socket)" do
696
+ # check_vite opens a socket via TCPSocket.new; a single as_json must open it
697
+ # exactly once — proving #results is computed once, not per consumer.
698
+ doctor.as_json
699
+ expect(TCPSocket).to have_received(:new).once
700
+ end
701
+ end
702
+
703
+ describe "#run stays byte-identical after the #results refactor (Story 15.3, AC4a)", :story_15_3 do
704
+ subject(:doctor) { described_class.new }
705
+
706
+ before do
707
+ make_manifest
708
+ make_controller(with_include: true)
709
+ make_layout(with_sentinel: true)
710
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
711
+ end
712
+
713
+ it "prints exactly the header + one format_result line per check (no JSON/prose leak), returns true" do
714
+ # Reconstruct the expected output from the SAME format_result the human
715
+ # path uses — proves #run still emits header + one glyph line per check
716
+ # (the optional 3rd remediation element is ignored) and nothing else.
717
+ lines = doctor.results.map { |status, message| doctor.send(:format_result, status, message) }.join("\n")
718
+ expected = "[ruact] Health check\n#{lines}\n"
719
+
720
+ expect { expect(doctor.run).to be true }.to output(expected).to_stdout
721
+ end
722
+ end
723
+
514
724
  describe "Rake task definition (Story 5.12)", :story_5_12 do
515
725
  # Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
516
726
  # task table is isolated from any other spec that may have loaded tasks.
@@ -549,5 +759,11 @@ RSpec.describe Ruact::Doctor do
549
759
  # actions is a list of Procs; just assert at least one is attached
550
760
  expect(task.actions).not_to be_empty
551
761
  end
762
+
763
+ it "defines Rake::Task['ruact:routes'] with an action (Story 15.3)", :story_15_3 do
764
+ task = rake_app.lookup("ruact:routes")
765
+ expect(task).not_to be_nil
766
+ expect(task.actions).not_to be_empty
767
+ end
552
768
  end
553
769
  end
@@ -116,6 +116,151 @@ module Ruact
116
116
  end
117
117
  end
118
118
 
119
+ # Story 15.2 (FR106) — children inside a PascalCase component tag (a matching
120
+ # closing tag) fail LOUDLY at preprocess time instead of degrading silently.
121
+ describe "loud children error (FR106)", :story_15_2 do
122
+ def run(source, identifier: nil)
123
+ described_class.transform(source, identifier: identifier)
124
+ end
125
+
126
+ it "raises on `<Card>Hello</Card>` naming component + fix (AC#1)" do
127
+ expect { run("<Card>Hello</Card>") }
128
+ .to raise_error(ChildrenNotSupportedError) do |e|
129
+ expect(e.message).to include("Card")
130
+ expect(e.message).to include("children are not supported")
131
+ expect(e.message).to include("pass content as a prop")
132
+ expect(e.message).to include("<Card content={...} />")
133
+ end
134
+ end
135
+
136
+ it "is a Ruact::PreprocessorError (AC#1 — subclass IS-A base)" do
137
+ expect { run("<Card>Hello</Card>") }.to raise_error(Ruact::PreprocessorError)
138
+ end
139
+
140
+ it "raises on an empty pair `<Card></Card>` (AC#1)" do
141
+ expect { run("<Card></Card>") }.to raise_error(ChildrenNotSupportedError, /Card/)
142
+ end
143
+
144
+ it "raises on multi-line children (AC#1)" do
145
+ source = "<Card>\n <p>hi</p>\n</Card>"
146
+ expect { run(source) }.to raise_error(ChildrenNotSupportedError, /children are not supported/)
147
+ end
148
+
149
+ it "raises on a paired tag that also holds a nested component" do
150
+ expect { run("<Card><Button /></Card>") }
151
+ .to raise_error(ChildrenNotSupportedError, /<Card>/)
152
+ end
153
+
154
+ it "names the supplied identifier and the correct line for a non-first-line pair (AC#4)" do
155
+ source = "line1\nline2\n<Card>Hello</Card>"
156
+ expect { run(source, identifier: "app/views/posts/show.html.erb") }
157
+ .to raise_error(ChildrenNotSupportedError) do |e|
158
+ expect(e.message).to include("app/views/posts/show.html.erb:3")
159
+ end
160
+ end
161
+
162
+ it "reports the opening tag's line, honoring attributes on the opening tag" do
163
+ expect { run("<Card variant={:wide}>x</Card>", identifier: "t.erb") }
164
+ .to raise_error(ChildrenNotSupportedError, /t\.erb:1/)
165
+ end
166
+
167
+ # Regression (Codex Round 1, Patch 1): a MULTI-LINE Suspense opening tag
168
+ # must not shift the reported line — the `<Card>` below is physically on
169
+ # line 5 and must report :5 (Suspense is masked newline-for-newline).
170
+ it "reports the exact source line even after a multi-line Suspense opening" do
171
+ source = <<~ERB
172
+ <Suspense
173
+ fallback="loading"
174
+ delay="2.5">
175
+ </Suspense>
176
+ <Card>Hello</Card>
177
+ ERB
178
+ expect { run(source, identifier: "t.erb") }
179
+ .to raise_error(ChildrenNotSupportedError, /t\.erb:5/)
180
+ end
181
+
182
+ # Regression (Codex Round 2, Patch 1): a `</Dialog>` living inside an ERB
183
+ # island (Ruby string/comment) must NOT be mistaken for a real component
184
+ # closing tag — a valid bare `<Dialog open={true}>` stays valid.
185
+ it "does not false-pair a bare opening with a `</Tag>` inside an ERB island" do
186
+ source = %(<Dialog open={true}>\n<% x = "</Dialog>" %>)
187
+ expect { run(source) }.not_to raise_error
188
+ end
189
+
190
+ it "still fires when the closing tag is real ERB body, not inside `<% %>`" do
191
+ expect { run("<Card><%= @body %></Card>") }
192
+ .to raise_error(ChildrenNotSupportedError, /Card/)
193
+ end
194
+
195
+ # Regression (Codex Round 2, Patch 2): many bare non-self-closing openings
196
+ # with no close must stay linear (single-pass stack scan) and silent (D3).
197
+ it "stays silent and does not blow up on many bare unclosed openings" do
198
+ source = "<Dialog open={true}>\n" * 5000
199
+ expect { run(source) }.not_to raise_error
200
+ end
201
+
202
+ # Regression (Codex Round 3, Patch 1): stray/unmatched PascalCase closing
203
+ # tags (no preceding matching open) are literal text — never an error — and
204
+ # must stay linear (per-name O(1) lookup, no `rindex`). Correctness pin;
205
+ # perf verified live, not timed here (avoids a flaky benchmark spec).
206
+ it "does not raise on stray closing tags with no matching opening" do
207
+ source = "</Card>\n" * 5000
208
+ expect { run(source) }.not_to raise_error
209
+ end
210
+
211
+ it "still raises when a real opening precedes the stray closes" do
212
+ source = "<Card>x</Card>\n#{'</Card>' * 100}"
213
+ expect { run(source) }.to raise_error(ChildrenNotSupportedError, /Card/)
214
+ end
215
+
216
+ # AC#2 regression — the loud error must NOT fire on any valid pattern.
217
+ describe "does NOT fire on valid patterns (AC#2 byte-identical)" do
218
+ it "self-closing tag, no props" do
219
+ expect { run("<Button />") }.not_to raise_error
220
+ end
221
+
222
+ it "self-closing tag with props" do
223
+ expect { run("<LikeButton postId={@post.id} />") }.not_to raise_error
224
+ end
225
+
226
+ it "bare non-self-closing opening tag with NO closing tag (`<Dialog open={true}>`)" do
227
+ expect { run("<Dialog open={true}>") }.not_to raise_error
228
+ end
229
+
230
+ it "nested-brace prop value" do
231
+ expect { run("<Select options={Category.all.map { |c| c.id }} />") }.not_to raise_error
232
+ end
233
+
234
+ it "multiple self-closing components" do
235
+ expect { run('<Button /> and <Badge label={"hello"} />') }.not_to raise_error
236
+ end
237
+
238
+ it "mixed HTML around a self-closing component" do
239
+ source = %(<div class="container">\n <h1>Hi</h1>\n <LikeButton postId={1} />\n</div>)
240
+ expect { run(source) }.not_to raise_error
241
+ end
242
+
243
+ it "emits byte-identical output for `<Dialog open={true}>`" do
244
+ expect(run("<Dialog open={true}>"))
245
+ .to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
246
+ end
247
+ end
248
+
249
+ # AC#3 — Suspense children are the ONE legitimate paired PascalCase tag and
250
+ # must never trip the error (normalized to <ruact-suspense> in Step 1).
251
+ describe "Suspense children never trip the error (AC#3)" do
252
+ it "does not raise for `<Suspense><Spinner /></Suspense>`" do
253
+ expect { run(%(<Suspense fallback="loading"><Spinner /></Suspense>)) }.not_to raise_error
254
+ end
255
+
256
+ it "normalizes Suspense to <ruact-suspense> exactly as today" do
257
+ result = run(%(<Suspense fallback="loading"><Spinner /></Suspense>))
258
+ expect(result).to include(%(data-ruact-fallback="loading"))
259
+ expect(result).to include("</ruact-suspense>")
260
+ end
261
+ end
262
+ end
263
+
119
264
  # Story 13.5 (FR100) — preprocess-time component-contract validation, wired
120
265
  # through an injectable registry seam (a stub responding to +contract_for+).
121
266
  describe "component contract validation", :story_13_5 do