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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +378 -6
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +14 -46
- data/lib/ruact/doctor.rb +102 -12
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +222 -6
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +727 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- 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
|
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -23,10 +23,20 @@ RSpec.describe Ruact::Doctor do
|
|
|
23
23
|
File.write(dir.join("application_controller.rb"), content)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# `with_assets` is the second half of a migrated layout: the React root alone
|
|
27
|
+
# leaves ruact on its built-in (CSS-less) shell, so the two markers are
|
|
28
|
+
# independent inputs, not one sentinel.
|
|
29
|
+
def make_layout(with_sentinel: true, with_assets: true)
|
|
27
30
|
dir = tmpdir.join("app", "views", "layouts")
|
|
28
31
|
FileUtils.mkdir_p(dir)
|
|
29
|
-
content =
|
|
32
|
+
content =
|
|
33
|
+
if !with_sentinel
|
|
34
|
+
"<body></body>\n"
|
|
35
|
+
elsif with_assets
|
|
36
|
+
"<%# ruact: root %>\n<div id=\"root\"></div>\n<%= ruact_js_assets %>\n"
|
|
37
|
+
else
|
|
38
|
+
"<%# ruact: root %>\n<div id=\"root\"></div>\n"
|
|
39
|
+
end
|
|
30
40
|
File.write(dir.join("application.html.erb"), content)
|
|
31
41
|
end
|
|
32
42
|
|
|
@@ -131,8 +141,11 @@ RSpec.describe Ruact::Doctor do
|
|
|
131
141
|
describe "#check_layout (AC#1, #5)" do
|
|
132
142
|
subject(:doctor) { described_class.new }
|
|
133
143
|
|
|
134
|
-
context "when layout
|
|
135
|
-
before
|
|
144
|
+
context "when the layout is wired and the app opted in" do
|
|
145
|
+
before do
|
|
146
|
+
make_layout(with_sentinel: true, with_assets: true)
|
|
147
|
+
Ruact.configure { |c| c.layout = true }
|
|
148
|
+
end
|
|
136
149
|
|
|
137
150
|
it "returns :pass" do
|
|
138
151
|
status, = doctor.send(:check_layout)
|
|
@@ -140,7 +153,60 @@ RSpec.describe Ruact::Doctor do
|
|
|
140
153
|
end
|
|
141
154
|
end
|
|
142
155
|
|
|
143
|
-
|
|
156
|
+
# Both halves are silent when wrong, and they are DIFFERENT fixes — "add one
|
|
157
|
+
# line to the layout" versus "flip one setting" — so they report separately.
|
|
158
|
+
context "when the layout is wired but Ruact.config.layout is false" do
|
|
159
|
+
before do
|
|
160
|
+
make_layout(with_sentinel: true, with_assets: true)
|
|
161
|
+
Ruact.configure { |c| c.layout = false }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "returns :warn naming the setting, not the layout" do
|
|
165
|
+
status, message, remediation = doctor.send(:check_layout)
|
|
166
|
+
|
|
167
|
+
expect(status).to eq(:warn)
|
|
168
|
+
expect(message).to include("config.layout is false")
|
|
169
|
+
expect(remediation).to include("config.layout = true")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "does not fail the doctor run" do
|
|
173
|
+
expect(described_class::SUCCESS_STATUSES).to include(:warn)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
context "when the layout has the React root but never calls ruact_js_assets" do
|
|
178
|
+
before do
|
|
179
|
+
make_layout(with_sentinel: true, with_assets: false)
|
|
180
|
+
Ruact.configure { |c| c.layout = true }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "fails and names the helper to add" do
|
|
184
|
+
status, _message, remediation = doctor.send(:check_layout)
|
|
185
|
+
|
|
186
|
+
expect(status).to eq(:fail)
|
|
187
|
+
expect(remediation).to include("ruact_js_assets")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The drift this shares with the runtime and the generator: a MENTION of the
|
|
192
|
+
# helper in a comment is not a call, and reporting :pass on one sent a
|
|
193
|
+
# developer looking anywhere but at the real problem.
|
|
194
|
+
context "when the layout only mentions the helper in a comment" do
|
|
195
|
+
before do
|
|
196
|
+
dir = tmpdir.join("app", "views", "layouts")
|
|
197
|
+
FileUtils.mkdir_p(dir)
|
|
198
|
+
File.write(dir.join("application.html.erb"),
|
|
199
|
+
"<%# ruact: root %>\n<div id=\"root\"></div>\n<%# TODO: add ruact_js_assets %>\n")
|
|
200
|
+
Ruact.configure { |c| c.layout = true }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "does not report it as wired" do
|
|
204
|
+
status, = doctor.send(:check_layout)
|
|
205
|
+
expect(status).to eq(:fail)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
context "when the layout has neither the root nor the helper" do
|
|
144
210
|
before { make_layout(with_sentinel: false) }
|
|
145
211
|
|
|
146
212
|
it "returns :fail" do
|
|
@@ -148,8 +214,17 @@ RSpec.describe Ruact::Doctor do
|
|
|
148
214
|
expect(status).to eq(:fail)
|
|
149
215
|
end
|
|
150
216
|
|
|
151
|
-
it "
|
|
217
|
+
it "names both halves, since either could be the missing one" do
|
|
152
218
|
_, msg = doctor.send(:check_layout)
|
|
219
|
+
expect(msg).to include("React root").and include("ruact_js_assets")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
context "when there is no application layout file at all" do
|
|
224
|
+
it "reports the file, not its contents" do
|
|
225
|
+
status, msg = doctor.send(:check_layout)
|
|
226
|
+
|
|
227
|
+
expect(status).to eq(:fail)
|
|
153
228
|
expect(msg).to eq("React shell missing from application.html.erb")
|
|
154
229
|
end
|
|
155
230
|
end
|
|
@@ -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
|