ruact 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,769 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "tmpdir"
5
- require "fileutils"
6
- require "socket"
7
-
8
- # The Rails stub (including Rails.root) is provided by spec/support/rails_stub.rb.
9
- require "ruact/doctor"
10
-
11
- RSpec.describe Ruact::Doctor do
12
- let(:tmpdir) { Pathname.new(Dir.mktmpdir) }
13
-
14
- before { Rails.root = tmpdir }
15
- after { FileUtils.rm_rf(tmpdir) }
16
-
17
- # --- helpers ---
18
-
19
- def make_controller(with_include: true)
20
- dir = tmpdir.join("app", "controllers")
21
- FileUtils.mkdir_p(dir)
22
- content = with_include ? "include Ruact::Controller\n" : "class ApplicationController\nend\n"
23
- File.write(dir.join("application_controller.rb"), content)
24
- end
25
-
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)
30
- dir = tmpdir.join("app", "views", "layouts")
31
- FileUtils.mkdir_p(dir)
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
40
- File.write(dir.join("application.html.erb"), content)
41
- end
42
-
43
- def make_manifest
44
- dir = tmpdir.join("public")
45
- FileUtils.mkdir_p(dir)
46
- File.write(dir.join("react-client-manifest.json"), "{}")
47
- end
48
-
49
- # --- check_manifest ---
50
-
51
- describe "#check_manifest (AC#1, #2)" do
52
- subject(:doctor) { described_class.new }
53
-
54
- context "when manifest file exists" do
55
- before { make_manifest }
56
-
57
- it "returns :pass" do
58
- status, = doctor.send(:check_manifest)
59
- expect(status).to eq(:pass)
60
- end
61
-
62
- it "message includes 'Manifest found at'" do
63
- _, msg = doctor.send(:check_manifest)
64
- expect(msg).to include("Manifest found at")
65
- end
66
- end
67
-
68
- context "when manifest file is missing" do
69
- it "returns :fail" do
70
- status, = doctor.send(:check_manifest)
71
- expect(status).to eq(:fail)
72
- end
73
-
74
- it "message is 'Manifest not found — run vite build'" do
75
- _, msg = doctor.send(:check_manifest)
76
- expect(msg).to eq("Manifest not found — run vite build")
77
- end
78
- end
79
- end
80
-
81
- # --- check_vite ---
82
-
83
- describe "#check_vite (AC#1, #3)" do
84
- subject(:doctor) { described_class.new }
85
-
86
- context "when Vite is accessible" do
87
- before { allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil)) }
88
-
89
- it "returns :pass" do
90
- status, = doctor.send(:check_vite)
91
- expect(status).to eq(:pass)
92
- end
93
- end
94
-
95
- context "when Vite is not accessible" do
96
- before { allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED) }
97
-
98
- it "returns :fail" do
99
- status, = doctor.send(:check_vite)
100
- expect(status).to eq(:fail)
101
- end
102
-
103
- it "message is 'Vite not accessible at localhost:5173 — run npm run dev'" do
104
- _, msg = doctor.send(:check_vite)
105
- expect(msg).to eq("Vite not accessible at localhost:5173 — run npm run dev")
106
- end
107
- end
108
- end
109
-
110
- # --- check_controller ---
111
-
112
- describe "#check_controller (AC#1, #4)" do
113
- subject(:doctor) { described_class.new }
114
-
115
- context "when ApplicationController includes Ruact::Controller" do
116
- before { make_controller(with_include: true) }
117
-
118
- it "returns :pass" do
119
- status, = doctor.send(:check_controller)
120
- expect(status).to eq(:pass)
121
- end
122
- end
123
-
124
- context "when Ruact::Controller is not included" do
125
- before { make_controller(with_include: false) }
126
-
127
- it "returns :fail" do
128
- status, = doctor.send(:check_controller)
129
- expect(status).to eq(:fail)
130
- end
131
-
132
- it "message is 'Ruact::Controller not included in ApplicationController'" do
133
- _, msg = doctor.send(:check_controller)
134
- expect(msg).to eq("Ruact::Controller not included in ApplicationController")
135
- end
136
- end
137
- end
138
-
139
- # --- check_layout ---
140
-
141
- describe "#check_layout (AC#1, #5)" do
142
- subject(:doctor) { described_class.new }
143
-
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
149
-
150
- it "returns :pass" do
151
- status, = doctor.send(:check_layout)
152
- expect(status).to eq(:pass)
153
- end
154
- end
155
-
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
210
- before { make_layout(with_sentinel: false) }
211
-
212
- it "returns :fail" do
213
- status, = doctor.send(:check_layout)
214
- expect(status).to eq(:fail)
215
- end
216
-
217
- it "names both halves, since either could be the missing one" do
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)
228
- expect(msg).to eq("React shell missing from application.html.erb")
229
- end
230
- end
231
- end
232
-
233
- # --- check_streaming ---
234
-
235
- describe "#check_streaming (AC#5)" do
236
- subject(:doctor) { described_class.new }
237
-
238
- after { Ruact.streaming_mode = nil }
239
-
240
- it "always returns :pass" do
241
- status, = doctor.send(:check_streaming)
242
- expect(status).to eq(:pass)
243
- end
244
-
245
- context "when streaming_mode is :enabled (AC#1, #5)" do
246
- before do
247
- Ruact.streaming_mode = :enabled
248
- stub_const("Puma", Module.new)
249
- end
250
-
251
- it "message includes 'enabled' and 'Puma'" do
252
- _, msg = doctor.send(:check_streaming)
253
- expect(msg).to include("enabled").and include("Puma")
254
- end
255
- end
256
-
257
- context "when streaming_mode is :buffered with no known server (AC#3, #5)" do
258
- before { Ruact.streaming_mode = :buffered }
259
-
260
- it "message includes 'buffered'" do
261
- _, msg = doctor.send(:check_streaming)
262
- expect(msg).to include("buffered")
263
- end
264
- end
265
-
266
- context "when streaming_mode is nil (not yet detected)" do
267
- before { Ruact.streaming_mode = nil }
268
-
269
- it "defaults to buffered in the message" do
270
- _, msg = doctor.send(:check_streaming)
271
- expect(msg).to include("buffered")
272
- end
273
- end
274
- end
275
-
276
- # --- check_legacy_constant ---
277
-
278
- describe "#check_legacy_constant (Story 5.1)" do
279
- subject(:doctor) { described_class.new }
280
-
281
- # Built via Array#join so this spec file passes the gem-CI
282
- # `name-propagation` guard without an exclusion (Story 5.1 review F4).
283
- let(:legacy_const) { %w[Rails Rsc].join }
284
- let(:legacy_gem) { %w[rails rsc].join("_") }
285
-
286
- def make_initializer(content, filename: "ruact.rb")
287
- dir = tmpdir.join("config", "initializers")
288
- FileUtils.mkdir_p(dir)
289
- File.write(dir.join(filename), content)
290
- end
291
-
292
- def make_app_file(content, path:)
293
- dir = tmpdir.join("app", File.dirname(path))
294
- FileUtils.mkdir_p(dir)
295
- File.write(tmpdir.join("app", path), content)
296
- end
297
-
298
- context "when no legacy references exist" do
299
- it "returns :pass" do
300
- status, = doctor.send(:check_legacy_constant)
301
- expect(status).to eq(:pass)
302
- end
303
- end
304
-
305
- context "when an initializer references the legacy constant" do
306
- before { make_initializer("#{legacy_const}.configure do |c|\n c.foo = 1\nend\n") }
307
-
308
- it "returns :fail" do
309
- status, = doctor.send(:check_legacy_constant)
310
- expect(status).to eq(:fail)
311
- end
312
-
313
- it "message names the file:line and instructs the rename" do
314
- _, msg = doctor.send(:check_legacy_constant)
315
- expect(msg).to include("ruact.rb:1")
316
- expect(msg).to include("Replace `#{legacy_const}` with `Ruact`")
317
- end
318
-
319
- it "message includes the rename documentation link (AC5)" do
320
- _, msg = doctor.send(:check_legacy_constant)
321
- expect(msg).to include("https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed")
322
- end
323
- end
324
-
325
- context "when an app file requires the legacy gem name" do
326
- before { make_app_file("require \"#{legacy_gem}\"\n", path: "models/foo.rb") }
327
-
328
- it "returns :fail" do
329
- status, = doctor.send(:check_legacy_constant)
330
- expect(status).to eq(:fail)
331
- end
332
- end
333
-
334
- context "when only the modern Ruact constant is referenced" do
335
- before { make_initializer("Ruact.configure { |c| c.foo = 1 }\n") }
336
-
337
- it "returns :pass" do
338
- status, = doctor.send(:check_legacy_constant)
339
- expect(status).to eq(:pass)
340
- end
341
- end
342
-
343
- context "when a word coincidentally contains the legacy substring" do
344
- # e.g. "TrailsRsce" should not trigger the regex (boundary check).
345
- before { make_initializer("# documenting TrailsRsce_engine here\n") }
346
-
347
- it "returns :pass" do
348
- status, = doctor.send(:check_legacy_constant)
349
- expect(status).to eq(:pass)
350
- end
351
- end
352
- end
353
-
354
- # --- check_serialize_only (Story 13.1, AC2 + AC4) ---
355
-
356
- describe "#check_serialize_only", :story_13_1 do
357
- subject(:doctor) { described_class.new(serialize_only_root: scan_root.to_s) }
358
-
359
- # Injectable scan root → point the tripwire at a fixture tree.
360
- let(:scan_root) { Pathname.new(Dir.mktmpdir) }
361
-
362
- after { FileUtils.rm_rf(scan_root) }
363
-
364
- def write_source(name, content)
365
- path = scan_root.join(name)
366
- FileUtils.mkdir_p(path.dirname)
367
- File.write(path, content)
368
- end
369
-
370
- context "with a clean tree (no inbound deserializer)" do
371
- before { write_source("clean.rb", "class Foo\n def bar = 42\nend\n") }
372
-
373
- it "returns :pass silently" do
374
- status, msg = doctor.send(:check_serialize_only)
375
- expect(status).to eq(:pass)
376
- expect(msg).to include("Serialize-only invariant holds")
377
- end
378
- end
379
-
380
- context "with an unguarded inbound Flight deserializer" do
381
- before do
382
- write_source("evil.rb", "class FlightDeserializer\n def call(body)\n parse_flight(body)\n end\nend\n")
383
- end
384
-
385
- it "returns :fail" do
386
- status, = doctor.send(:check_serialize_only)
387
- expect(status).to eq(:fail)
388
- end
389
-
390
- it "names the offending file:line and points to the ADR invariant" do
391
- _, msg = doctor.send(:check_serialize_only)
392
- expect(msg).to include("evil.rb:1") # first offense = the *Deserializer constant
393
- expect(msg).to include("CVE-2025-55182")
394
- expect(msg).to include("server-functions-api.md")
395
- end
396
- end
397
-
398
- context "with a deserializer carrying the allow annotation" do
399
- before do
400
- annotation = ["# ruact:allow", "flight", "deserialization"].join("-")
401
- write_source("guarded.rb", "def parse_flight(body) #{annotation} reviewed legacy bridge\n body\nend\n")
402
- end
403
-
404
- it "returns :pass (the escape hatch makes it a guard, not a ban)" do
405
- status, = doctor.send(:check_serialize_only)
406
- expect(status).to eq(:pass)
407
- end
408
- end
409
-
410
- context "with a signal that lives in an excluded location" do
411
- before do
412
- # generators' client-side templates are out of scope (browser RSC)
413
- write_source("lib/generators/ruact/install/templates/app.rb", "def parse_flight(b) = b\n")
414
- end
415
-
416
- it "returns :pass (templates are excluded from the scan)" do
417
- status, = doctor.send(:check_serialize_only)
418
- expect(status).to eq(:pass)
419
- end
420
- end
421
-
422
- context "with a `decode_flight` inbound entry point (review finding R3)" do
423
- before { write_source("decoder.rb", "def decode_flight(body)\n body\nend\n") }
424
-
425
- it "returns :fail (decode_flight is an inbound deserialization signal)" do
426
- status, = doctor.send(:check_serialize_only)
427
- expect(status).to eq(:fail)
428
- end
429
- end
430
-
431
- context "with a deserializer in a differently-located file ALSO named doctor.rb (review finding R1)" do
432
- # Only the gem's own lib/ruact/doctor.rb is excluded (by exact path), not
433
- # every basename `doctor.rb` — a nested namesake must still be scanned.
434
- before { write_source("ruact/flight/doctor.rb", "class FlightDeserializer; end\n") }
435
-
436
- it "returns :fail (basename collision is not a free pass)" do
437
- status, = doctor.send(:check_serialize_only)
438
- expect(status).to eq(:fail)
439
- end
440
- end
441
- end
442
-
443
- # --- check_flight_middleware (Story 13.1, AC3 + AC4) ---
444
-
445
- describe "#check_flight_middleware", :story_13_1 do
446
- subject(:doctor) { described_class.new }
447
-
448
- # Plain value objects (not doubles) modelling the iterable middleware stack
449
- # the check reads — each entry exposes a `.name`, mirroring a real
450
- # ActionDispatch::MiddlewareStack::Middleware.
451
- def middleware_entry(name)
452
- Struct.new(:name).new(name)
453
- end
454
-
455
- def stub_app(stack)
456
- app = Struct.new(:middleware).new(stack)
457
- allow(Rails).to receive(:application).and_return(app)
458
- end
459
-
460
- context "when a response-transforming middleware (Rack::Deflater) is mounted" do
461
- before { stub_app([middleware_entry("Rack::Deflater")]) }
462
-
463
- it "returns :warn (never :fail)" do
464
- status, msg = doctor.send(:check_flight_middleware)
465
- expect(status).to eq(:warn)
466
- expect(msg).to include("Rack::Deflater").and include("text/x-component")
467
- end
468
- end
469
-
470
- context "when no response-transforming middleware is mounted" do
471
- before { stub_app([middleware_entry("Rack::Runtime")]) }
472
-
473
- it "returns :pass" do
474
- status, = doctor.send(:check_flight_middleware)
475
- expect(status).to eq(:pass)
476
- end
477
- end
478
-
479
- context "when no Rails application is present" do
480
- before { allow(Rails).to receive(:application).and_return(nil) }
481
-
482
- it "returns :pass (guarded edge context)" do
483
- status, = doctor.send(:check_flight_middleware)
484
- expect(status).to eq(:pass)
485
- end
486
- end
487
-
488
- context "when the app is not yet booted (middleware is a non-enumerable proxy)" do
489
- # Mirrors a pre-`initialize!` Rails::Configuration::MiddlewareStackProxy,
490
- # which does NOT respond to :each — must be skipped, not crash on filter_map.
491
- before do
492
- proxy = Object.new # responds to neither :each nor :filter_map
493
- app = Struct.new(:middleware).new(proxy)
494
- allow(Rails).to receive(:application).and_return(app)
495
- end
496
-
497
- it "returns :pass without raising" do
498
- expect { doctor.send(:check_flight_middleware) }.not_to raise_error
499
- status, = doctor.send(:check_flight_middleware)
500
- expect(status).to eq(:pass)
501
- end
502
- end
503
- end
504
-
505
- # --- :warn status semantics (Story 13.1, AC3) ---
506
-
507
- describe "#format_result with :warn", :story_13_1 do
508
- subject(:doctor) { described_class.new }
509
-
510
- it "renders :warn with the ⚠ glyph (not ✗)" do
511
- expect(doctor.send(:format_result, :warn, "heads up")).to eq("⚠ heads up")
512
- end
513
- end
514
-
515
- describe "#run with an unexpected status (review finding R1)", :story_13_1 do
516
- before do
517
- make_manifest
518
- make_controller(with_include: true)
519
- make_layout(with_sentinel: true)
520
- allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
521
- end
522
-
523
- it "fails the run when a check returns a status that is neither :pass nor :warn" do
524
- doctor = described_class.new
525
- # All other checks pass; a malformed status (rendered ✗) must NOT be
526
- # silently treated as a pass — only :pass / :warn are success.
527
- allow(doctor).to receive(:check_streaming).and_return([:error, "broken status"])
528
- expect(doctor.run).to be false
529
- end
530
- end
531
-
532
- describe ".run with a :warn present (Story 13.1, AC3)", :story_13_1 do
533
- before do
534
- make_manifest
535
- make_controller(with_include: true)
536
- make_layout(with_sentinel: true)
537
- allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
538
- app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
539
- allow(Rails).to receive(:application).and_return(app)
540
- end
541
-
542
- it "still returns true — a :warn does not fail the run" do
543
- expect(described_class.run).to be true
544
- end
545
-
546
- it "prints the warning glyph" do
547
- expect { described_class.run }.to output(/⚠.*Rack::Deflater/).to_stdout
548
- end
549
-
550
- it "does not print the fix hint" do
551
- expect { described_class.run }.not_to output(/rails generate/).to_stdout
552
- end
553
- end
554
-
555
- # --- run / .run ---
556
-
557
- describe ".run / #run (AC#1, #7)" do
558
- before do
559
- make_manifest
560
- make_controller(with_include: true)
561
- make_layout(with_sentinel: true)
562
- allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
563
- end
564
-
565
- context "when all checks pass" do
566
- it "returns true" do
567
- expect(described_class.run).to be true
568
- end
569
-
570
- it "does not print the fix hint" do
571
- expect { described_class.run }.not_to output(/rails generate/).to_stdout
572
- end
573
- end
574
-
575
- context "when any check fails" do
576
- before { allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED) }
577
-
578
- it "returns false" do
579
- expect(described_class.run).to be false
580
- end
581
-
582
- it "prints the fix hint" do
583
- expect { described_class.run }
584
- .to output(/Run rails generate ruact:install to fix configuration issues/).to_stdout
585
- end
586
- end
587
- end
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
-
724
- describe "Rake task definition (Story 5.12)", :story_5_12 do
725
- # Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
726
- # task table is isolated from any other spec that may have loaded tasks.
727
- # Asserts the new namespace is discoverable AND the legacy namespace is
728
- # gone — guards against a partial rename (e.g. file renamed but the
729
- # namespace inside left as :rsc, or vice-versa).
730
- around do |example|
731
- require "rake"
732
- prev = Rake.application
733
- Rake.application = Rake::Application.new
734
- Rake.application.add_loader("rake", Rake::DefaultLoader.new)
735
- Rake.application.add_loader("rb", Rake::DefaultLoader.new)
736
- load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
737
- example.run
738
- ensure
739
- Rake.application = prev
740
- end
741
-
742
- let(:rake_app) { Rake.application }
743
-
744
- it "defines Rake::Task['ruact:doctor']" do
745
- expect(rake_app.lookup("ruact:doctor")).not_to be_nil
746
- end
747
-
748
- it "does NOT define the legacy Rake task under the pre-Story-5.12 namespace" do
749
- # Build the legacy task name from fragments so this spec file stays
750
- # grep-clean under the Story 5.12 CI guard (which rejects the literal
751
- # `<legacy>:doctor` substring anywhere in tracked files).
752
- legacy_task = %w[rsc doctor].join(":")
753
- expect(rake_app.lookup(legacy_task)).to be_nil
754
- end
755
-
756
- it "the ruact:doctor task action invokes Ruact::Doctor.run" do
757
- task = rake_app.lookup("ruact:doctor")
758
- expect(task).not_to be_nil
759
- # actions is a list of Procs; just assert at least one is attached
760
- expect(task.actions).not_to be_empty
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
768
- end
769
- end