ruact 0.0.7 → 0.0.8
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 +24 -0
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +9 -0
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -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/tasks/ruact.rake +55 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -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 +14 -2
|
@@ -138,6 +138,47 @@ module ServerBucketSpecSupport
|
|
|
138
138
|
post.valid?
|
|
139
139
|
ruact_errors(post)
|
|
140
140
|
end
|
|
141
|
+
|
|
142
|
+
# Story 15.0 (F6) — the injection-BYPASS case the dev warning targets:
|
|
143
|
+
# registers errors, then renders explicitly on the same branch, so
|
|
144
|
+
# `default_render` never runs and the errors vanish from the JSON body.
|
|
145
|
+
def opt_out_render
|
|
146
|
+
post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
|
|
147
|
+
post.valid?
|
|
148
|
+
ruact_errors(post)
|
|
149
|
+
render json: { "ok" => false }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Story 15.0 (F6 guardrail a) — the documented-correct Bucket-1 pattern:
|
|
153
|
+
# register errors, render the form page again (the template binds the
|
|
154
|
+
# `ruact_errors` reader). Requested WITHOUT the function-call Accept.
|
|
155
|
+
def failed_with_page_render
|
|
156
|
+
post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
|
|
157
|
+
post.valid?
|
|
158
|
+
ruact_errors(post)
|
|
159
|
+
render :new_form
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Story 15.0 (F6 guardrail b) — register errors, then redirect (the
|
|
163
|
+
# redirect-back round-trip). Correct on BOTH buckets, must never warn.
|
|
164
|
+
def failed_with_redirect
|
|
165
|
+
post = ServerBucketSpecSupport::ValidatedPost.new(title: nil)
|
|
166
|
+
post.valid?
|
|
167
|
+
ruact_errors(post)
|
|
168
|
+
redirect_to "/posts/new"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Story 15.0 (F6 guardrail d) — explicit render with an UNTOUCHED collector.
|
|
172
|
+
def untouched_explicit_render
|
|
173
|
+
render json: { "ok" => true }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Story 15.5 (FR109) — a plain Rails action on a `Ruact::Server` controller:
|
|
177
|
+
# a GET that renders plain text, so it is neither a function call nor a ruact
|
|
178
|
+
# page render. The dev shape-log must stay SILENT on it (non-negotiated).
|
|
179
|
+
def plain_rails
|
|
180
|
+
render plain: "just rails"
|
|
181
|
+
end
|
|
141
182
|
end
|
|
142
183
|
|
|
143
184
|
# Review round 2 — an auth-style before_action that redirects on a Bucket-2
|
|
@@ -211,6 +252,15 @@ ControllerRequestSpecSupport.write_view(
|
|
|
211
252
|
ERB
|
|
212
253
|
)
|
|
213
254
|
|
|
255
|
+
# Story 15.0 (F6 guardrail a) — the re-rendered form page for the documented
|
|
256
|
+
# Bucket-1 `ruact_errors(record); render :new_form` pattern. No component tags
|
|
257
|
+
# on purpose (the guardrail is about the warn predicate, not the pipeline).
|
|
258
|
+
ControllerRequestSpecSupport.write_view(
|
|
259
|
+
"server_bucket_spec_support/bucket_server", "new_form", <<~ERB
|
|
260
|
+
<div>the form again</div>
|
|
261
|
+
ERB
|
|
262
|
+
)
|
|
263
|
+
|
|
214
264
|
if defined?(ControllerRequestSpecSupport) &&
|
|
215
265
|
!ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_bucket_routes_appended)
|
|
216
266
|
ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_bucket_routes_appended, true)
|
|
@@ -227,6 +277,15 @@ if defined?(ControllerRequestSpecSupport) &&
|
|
|
227
277
|
post "/bucket/validated_create_invalid", to: "server_bucket_spec_support/bucket_server#validated_create_invalid"
|
|
228
278
|
post "/bucket/validated_create_valid", to: "server_bucket_spec_support/bucket_server#validated_create_valid"
|
|
229
279
|
post "/bucket/stray_errors_ivar", to: "server_bucket_spec_support/bucket_server#stray_errors_ivar"
|
|
280
|
+
# Story 15.0 (F6) — the warning truth-table routes.
|
|
281
|
+
post "/bucket/opt_out_render", to: "server_bucket_spec_support/bucket_server#opt_out_render"
|
|
282
|
+
post "/bucket/failed_with_page_render",
|
|
283
|
+
to: "server_bucket_spec_support/bucket_server#failed_with_page_render"
|
|
284
|
+
post "/bucket/failed_with_redirect", to: "server_bucket_spec_support/bucket_server#failed_with_redirect"
|
|
285
|
+
post "/bucket/untouched_explicit_render",
|
|
286
|
+
to: "server_bucket_spec_support/bucket_server#untouched_explicit_render"
|
|
287
|
+
# Story 15.5 (FR109) — a plain Rails GET on a Server controller (non-negotiated).
|
|
288
|
+
get "/bucket/plain_rails", to: "server_bucket_spec_support/bucket_server#plain_rails"
|
|
230
289
|
post "/bucket/dual_redirecting", to: "server_bucket_spec_support/bucket_dual#redirecting"
|
|
231
290
|
post "/bucket/protected", to: "server_bucket_spec_support/bucket_forgery#create_protected"
|
|
232
291
|
get "/bucket/csrf_token", to: "server_bucket_spec_support/bucket_forgery#csrf_token"
|
|
@@ -472,6 +531,124 @@ RSpec.describe "Story 9.2: Ruact::Server dual-bucket response negotiation", :sto
|
|
|
472
531
|
# NON-raised 200 path above; the two contracts stay distinct.
|
|
473
532
|
end
|
|
474
533
|
|
|
534
|
+
# Story 15.0 (F6) — the dev-mode injection opt-out warning truth table.
|
|
535
|
+
# Fires ONLY on: development + function-call request + touched collector +
|
|
536
|
+
# explicit host render. Silent on every documented-correct pattern.
|
|
537
|
+
describe "Story 15.0: dev-mode ruact_errors injection opt-out warning (F6)", :story_15_0 do
|
|
538
|
+
# The distinctive fragment of the F6 message (deliberately distinct from
|
|
539
|
+
# any other `[ruact]` log line, incl. the future 15.5 bucket log).
|
|
540
|
+
let(:warning_re) { /\[ruact\] .*called `ruact_errors` and then rendered explicitly/ }
|
|
541
|
+
let(:html_form_headers) do
|
|
542
|
+
{ "CONTENT_TYPE" => "application/x-www-form-urlencoded", "HTTP_ACCEPT" => "text/html" }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
before do
|
|
546
|
+
allow(Rails.logger).to receive(:warn)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# NOTE: the suite's default Rails.env is "development" (RAILS_ENV unset),
|
|
550
|
+
# so the dev examples stub it EXPLICITLY anyway (they must not depend on
|
|
551
|
+
# the suite default) and the non-dev example stubs a non-dev env.
|
|
552
|
+
def stub_env(name)
|
|
553
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def stub_development_env
|
|
557
|
+
stub_env("development")
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
it "warns exactly once on an explicit render after ruact_errors on a function-call request" do
|
|
561
|
+
stub_development_env
|
|
562
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
563
|
+
|
|
564
|
+
expect(Rails.logger).to have_received(:warn).with(
|
|
565
|
+
a_string_matching(/\[ruact\] .*BucketServerController#opt_out_render.*ruact_errors.*NOT.*injected/m)
|
|
566
|
+
).once
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it "names the fix in the message (fall through, page-render binding, or redirect)" do
|
|
570
|
+
stub_development_env
|
|
571
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
572
|
+
|
|
573
|
+
expect(Rails.logger).to have_received(:warn).with(
|
|
574
|
+
a_string_matching(/fall.*through.*errors=\{ruact_errors\}.*redirect_to/m)
|
|
575
|
+
)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
it "keeps the response byte-identical whether the warning fires or not (log-only)" do
|
|
579
|
+
stub_env("production")
|
|
580
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
581
|
+
silent_status = last_response.status
|
|
582
|
+
silent_body = last_response.body
|
|
583
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
584
|
+
|
|
585
|
+
stub_development_env
|
|
586
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
587
|
+
|
|
588
|
+
expect(Rails.logger).to have_received(:warn).with(a_string_matching(warning_re))
|
|
589
|
+
expect(last_response.status).to eq(silent_status)
|
|
590
|
+
expect(last_response.body).to eq(silent_body)
|
|
591
|
+
expect(JSON.parse(last_response.body)).to eq("ok" => false)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
it "guardrail (a): silent on the documented Bucket-1 `ruact_errors(record); render :new` pattern" do
|
|
595
|
+
stub_development_env
|
|
596
|
+
post "/bucket/failed_with_page_render", "post[title]=", html_form_headers
|
|
597
|
+
|
|
598
|
+
expect(last_response.status).to eq(200)
|
|
599
|
+
expect(last_response.body).to include("the form again")
|
|
600
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
it "guardrail (b): silent on `ruact_errors(record); redirect_to` on a function-call request" do
|
|
604
|
+
stub_development_env
|
|
605
|
+
post "/bucket/failed_with_redirect", "{}", json_headers
|
|
606
|
+
|
|
607
|
+
expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/new")
|
|
608
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
it "guardrail (b): silent on `ruact_errors(record); redirect_to` on a Bucket-1 request" do
|
|
612
|
+
stub_development_env
|
|
613
|
+
post "/bucket/failed_with_redirect", "post[title]=", html_form_headers
|
|
614
|
+
|
|
615
|
+
expect(last_response.status).to eq(302)
|
|
616
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
it "guardrail (c): silent on the fall-through itself (errors were injected)" do
|
|
620
|
+
stub_development_env
|
|
621
|
+
post "/bucket/validated_create_invalid", "{}", json_headers
|
|
622
|
+
|
|
623
|
+
expect(JSON.parse(last_response.body).fetch("errors")).to eq("title" => ["Title can't be blank"])
|
|
624
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
it "guardrail (d): silent on an explicit render when the collector was never touched" do
|
|
628
|
+
stub_development_env
|
|
629
|
+
post "/bucket/untouched_explicit_render", "{}", json_headers
|
|
630
|
+
|
|
631
|
+
expect(JSON.parse(last_response.body)).to eq("ok" => true)
|
|
632
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
it "never warns outside development (the bypass case in the test env stays silent)" do
|
|
636
|
+
stub_env("test")
|
|
637
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
638
|
+
|
|
639
|
+
expect(last_response.status).to eq(200)
|
|
640
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
it "never warns in production either" do
|
|
644
|
+
stub_env("production")
|
|
645
|
+
post "/bucket/opt_out_render", "{}", json_headers
|
|
646
|
+
|
|
647
|
+
expect(last_response.status).to eq(200)
|
|
648
|
+
expect(Rails.logger).not_to have_received(:warn).with(a_string_matching(warning_re))
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
475
652
|
# Story 10.0 (AC5) — the Server-path inherits the default_render graceful
|
|
476
653
|
# degradation fix. A GET is never a function call, so Server#default_render
|
|
477
654
|
# delegates to super → the fixed Controller#default_render.
|
|
@@ -491,4 +668,118 @@ RSpec.describe "Story 9.2: Ruact::Server dual-bucket response negotiation", :sto
|
|
|
491
668
|
expect(last_response.headers["Content-Type"]).to include("text/x-component")
|
|
492
669
|
end
|
|
493
670
|
end
|
|
671
|
+
|
|
672
|
+
# Story 15.5 (FR109) — the dev-mode response-shape legibility log. One
|
|
673
|
+
# `[ruact]` line per ruact-NEGOTIATED request naming the chosen bucket + the
|
|
674
|
+
# deciding signal. Log-only (byte-identical), dev-gated, SILENT on
|
|
675
|
+
# non-negotiated (plain Rails) responses. Mirrors the F6 warning test shape.
|
|
676
|
+
describe "Story 15.5: dev-mode response-shape log (FR109)", :story_15_5 do
|
|
677
|
+
# The distinctive fragment of the 15.5 log — deliberately distinct from the
|
|
678
|
+
# F6 warning line (which matches `called \`ruact_errors\` and then rendered`).
|
|
679
|
+
let(:shape_log_re) { /\[ruact\] .* — .* → / }
|
|
680
|
+
let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
|
|
681
|
+
let(:html_get_headers) { { "HTTP_ACCEPT" => "*/*" } }
|
|
682
|
+
|
|
683
|
+
before do
|
|
684
|
+
# The suite default env is development (RAILS_ENV unset); dev examples
|
|
685
|
+
# still stub it explicitly so they never depend on the suite default.
|
|
686
|
+
allow(Rails.logger).to receive(:info)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def stub_env(name)
|
|
690
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(name))
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def stub_development_env
|
|
694
|
+
stub_env("development")
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
it "logs the function-call JSON bucket once with the deciding signal (AC2)" do
|
|
698
|
+
stub_development_env
|
|
699
|
+
post "/bucket/with_ivars", "{}", json_headers
|
|
700
|
+
|
|
701
|
+
expect(Rails.logger).to have_received(:info).with(
|
|
702
|
+
"[ruact] ServerBucketSpecSupport::BucketServerController#with_ivars — " \
|
|
703
|
+
"Accept: application/json + POST → function-call JSON"
|
|
704
|
+
).once
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
it "names the 204 (no ivars) sub-shape" do
|
|
708
|
+
stub_development_env
|
|
709
|
+
post "/bucket/empty", "{}", json_headers
|
|
710
|
+
|
|
711
|
+
expect(last_response.status).to eq(204)
|
|
712
|
+
expect(Rails.logger).to have_received(:info).with(
|
|
713
|
+
a_string_matching(%r{#empty_action — Accept: application/json \+ POST → function-call JSON \(204, no ivars\)})
|
|
714
|
+
).once
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
it "names the $redirect sub-shape" do
|
|
718
|
+
stub_development_env
|
|
719
|
+
post "/bucket/redirecting", "{}", json_headers
|
|
720
|
+
|
|
721
|
+
expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/1")
|
|
722
|
+
expect(Rails.logger).to have_received(:info).with(
|
|
723
|
+
a_string_matching(%r{#redirecting — Accept: application/json \+ POST → function-call \$redirect})
|
|
724
|
+
).once
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
it "logs the Flight-page bucket on a text/x-component page render (AC2)" do
|
|
728
|
+
stub_development_env
|
|
729
|
+
get "/server-implicit/show", {}, flight_headers
|
|
730
|
+
|
|
731
|
+
expect(last_response.headers["Content-Type"]).to include("text/x-component")
|
|
732
|
+
expect(Rails.logger).to have_received(:info).with(
|
|
733
|
+
"[ruact] ServerBucketSpecSupport::ServerImplicitController#show — " \
|
|
734
|
+
"Accept: text/x-component → Flight page"
|
|
735
|
+
).once
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
it "logs the HTML-shell bucket on an HTML-acceptable page GET (AC2)" do
|
|
739
|
+
stub_development_env
|
|
740
|
+
get "/server-implicit/show", {}, html_get_headers
|
|
741
|
+
|
|
742
|
+
expect(last_response.headers["Content-Type"]).to include("text/html")
|
|
743
|
+
expect(Rails.logger).to have_received(:info).with(
|
|
744
|
+
"[ruact] ServerBucketSpecSupport::ServerImplicitController#show — " \
|
|
745
|
+
"HTML-acceptable Accept, no Flight header → HTML shell"
|
|
746
|
+
).once
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
it "is SILENT on a plain Rails action on the same controller (non-negotiated, AC2)" do
|
|
750
|
+
stub_development_env
|
|
751
|
+
get "/bucket/plain_rails"
|
|
752
|
+
|
|
753
|
+
expect(last_response.status).to eq(200)
|
|
754
|
+
expect(last_response.body).to eq("just rails")
|
|
755
|
+
expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
it "is SILENT outside development and byte-identical to the dev response (log-only, AC2)",
|
|
759
|
+
:aggregate_failures do
|
|
760
|
+
stub_env("production")
|
|
761
|
+
post "/bucket/with_ivars", "{}", json_headers
|
|
762
|
+
prod_status = last_response.status
|
|
763
|
+
prod_body = last_response.body
|
|
764
|
+
prod_ctype = last_response.headers["Content-Type"]
|
|
765
|
+
prod_vary = last_response.headers["Vary"]
|
|
766
|
+
expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
|
|
767
|
+
|
|
768
|
+
stub_development_env
|
|
769
|
+
post "/bucket/with_ivars", "{}", json_headers
|
|
770
|
+
expect(Rails.logger).to have_received(:info).with(a_string_matching(shape_log_re))
|
|
771
|
+
expect(last_response.status).to eq(prod_status)
|
|
772
|
+
expect(last_response.body).to eq(prod_body)
|
|
773
|
+
expect(last_response.headers["Content-Type"]).to eq(prod_ctype)
|
|
774
|
+
expect(last_response.headers["Vary"]).to eq(prod_vary)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
it "never logs in the test environment either" do
|
|
778
|
+
stub_env("test")
|
|
779
|
+
post "/bucket/with_ivars", "{}", json_headers
|
|
780
|
+
|
|
781
|
+
expect(last_response.status).to eq(200)
|
|
782
|
+
expect(Rails.logger).not_to have_received(:info).with(a_string_matching(shape_log_re))
|
|
783
|
+
end
|
|
784
|
+
end
|
|
494
785
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "active_support/all"
|
|
5
|
+
require "action_dispatch"
|
|
6
|
+
require "ruact/server_functions"
|
|
7
|
+
|
|
8
|
+
module Ruact
|
|
9
|
+
module ServerFunctions
|
|
10
|
+
# Plain query class — only `instance_method(...).parameters` (kwargs) and
|
|
11
|
+
# `.name` (collision origin) are read; no Rails boot needed. Defined at module
|
|
12
|
+
# level (mirrors query_source_spec) so it is not an example-group-leaky
|
|
13
|
+
# constant.
|
|
14
|
+
class CatalogIntrospectQ
|
|
15
|
+
def categories; end
|
|
16
|
+
|
|
17
|
+
def search_users(term:, limit: 10); end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Story 15.3 (FR107) — `ruact:routes --json` introspection. Exercised through
|
|
21
|
+
# a REAL RouteSet carrying both a mutation action and mounted query routes,
|
|
22
|
+
# with the host/query resolvers injected (no controller boot), so the document
|
|
23
|
+
# is proven byte-derived from RouteSource + QuerySource (the same single
|
|
24
|
+
# source of truth codegen consumes) and side-effect-free.
|
|
25
|
+
RSpec.describe Introspection, :story_15_3 do
|
|
26
|
+
let(:query_prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
|
|
27
|
+
|
|
28
|
+
# A real RouteSet with a mutation action (createPost, updatePost) AND two
|
|
29
|
+
# mounted query GET routes under the query dispatch controller prefix.
|
|
30
|
+
def combined_route_set
|
|
31
|
+
prefix = query_prefix
|
|
32
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
33
|
+
rs.draw do
|
|
34
|
+
resources :posts, only: %i[create update]
|
|
35
|
+
get "/q/categories", to: "#{prefix}catalog_introspect_q#categories"
|
|
36
|
+
get "/q/search_users", to: "#{prefix}catalog_introspect_q#search_users"
|
|
37
|
+
end
|
|
38
|
+
rs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def as_json(route_set)
|
|
42
|
+
described_class.as_json(
|
|
43
|
+
route_set: route_set,
|
|
44
|
+
host_predicate: ->(_controller) { true },
|
|
45
|
+
query_class_for: lambda { |controller|
|
|
46
|
+
controller.start_with?(query_prefix) ? CatalogIntrospectQ : nil
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe ".as_json" do
|
|
52
|
+
subject(:document) { as_json(combined_route_set) }
|
|
53
|
+
|
|
54
|
+
it "gates the document with the EXPERIMENTAL schema_version (0)", :aggregate_failures do
|
|
55
|
+
expect(document["schema_version"]).to eq(0)
|
|
56
|
+
expect(document["schema_version"]).to eq(described_class::SCHEMA_VERSION)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "round-trips through JSON.parse" do
|
|
60
|
+
expect(JSON.parse(JSON.generate(document))).to eq(document)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "reports BOTH action and query accessors sorted by name", :aggregate_failures do
|
|
64
|
+
names = document["accessors"].map { |a| a["accessor"] }
|
|
65
|
+
expect(names).to include("createPost", "updatePost", "categories", "searchUsers")
|
|
66
|
+
expect(names).to eq(names.sort)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "shapes an action with its required path segments as declared params", :aggregate_failures do
|
|
70
|
+
update = document["accessors"].find { |a| a["accessor"] == "updatePost" }
|
|
71
|
+
expect(update["kind"]).to eq("action")
|
|
72
|
+
expect(update["verb"]).to eq("PATCH")
|
|
73
|
+
expect(update["path"]).to eq("/posts/:id")
|
|
74
|
+
expect(update["segments"]).to eq(["id"])
|
|
75
|
+
expect(update["params"]).to eq([{ "name" => "id", "required" => true }])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "shapes a query with its declared kwargs as params (Story 13.4)", :aggregate_failures do
|
|
79
|
+
search = document["accessors"].find { |a| a["accessor"] == "searchUsers" }
|
|
80
|
+
expect(search["kind"]).to eq("query")
|
|
81
|
+
expect(search["verb"]).to eq("GET")
|
|
82
|
+
expect(search["path"]).to eq("/q/search_users")
|
|
83
|
+
expect(search["segments"]).to eq([])
|
|
84
|
+
expect(search["params"]).to eq(
|
|
85
|
+
[{ "name" => "term", "required" => true }, { "name" => "limit", "required" => false }]
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "carries exactly the AC2 keys per accessor" do
|
|
90
|
+
document["accessors"].each do |accessor|
|
|
91
|
+
expect(accessor.keys).to contain_exactly("accessor", "kind", "verb", "path", "segments", "params")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "does NOT include a per-accessor component-contract link (D3 = report accessors faithfully)" do
|
|
96
|
+
document["accessors"].each do |accessor|
|
|
97
|
+
expect(accessor).not_to have_key("contract")
|
|
98
|
+
expect(accessor).not_to have_key("has_contract")
|
|
99
|
+
expect(accessor).not_to have_key("contract_exists")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe "side-effect-free (AC2 / AC4d — writes NO bridge or TS)" do
|
|
105
|
+
it "never writes the codegen bridge / TS module", :aggregate_failures do
|
|
106
|
+
allow(SnapshotWriter).to receive(:write_if_changed!)
|
|
107
|
+
allow(Snapshot).to receive(:generate_v2!)
|
|
108
|
+
allow(Codegen).to receive(:generate_ts!)
|
|
109
|
+
allow(File).to receive(:write).and_call_original
|
|
110
|
+
|
|
111
|
+
as_json(combined_route_set)
|
|
112
|
+
|
|
113
|
+
expect(SnapshotWriter).not_to have_received(:write_if_changed!)
|
|
114
|
+
expect(Snapshot).not_to have_received(:generate_v2!)
|
|
115
|
+
expect(Codegen).not_to have_received(:generate_ts!)
|
|
116
|
+
expect(File).not_to have_received(:write)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe "single source of truth (AC2)" do
|
|
121
|
+
it "is byte-derived from ServerFunctions.introspect (same collectors as codegen)" do
|
|
122
|
+
# Both introspection and write_v2_snapshot! flow through .introspect;
|
|
123
|
+
# the document's accessors mirror those entries' identifiers exactly.
|
|
124
|
+
entries = ServerFunctions.introspect(
|
|
125
|
+
route_set: combined_route_set,
|
|
126
|
+
host_predicate: ->(_c) { true },
|
|
127
|
+
query_class_for: ->(c) { c.start_with?(query_prefix) ? CatalogIntrospectQ : nil }
|
|
128
|
+
)
|
|
129
|
+
expect(as_json(combined_route_set)["accessors"].map { |a| a["accessor"] })
|
|
130
|
+
.to eq(entries.map { |e| e["js_identifier"] })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "rake"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "action_controller"
|
|
8
|
+
require "ruact/server"
|
|
9
|
+
require "ruact/doctor"
|
|
10
|
+
require "ruact/server_functions"
|
|
11
|
+
|
|
12
|
+
# Story 15.3 (FR107) — end-to-end coverage of the `--json` Rake glue itself:
|
|
13
|
+
# the `-- --json` ARGV scan, JSON-only stdout (no human prose), and — for
|
|
14
|
+
# `ruact:doctor` — the preserved process exit code (0 pass/warn, 1 on fail).
|
|
15
|
+
# The `Doctor#as_json` / `Introspection.as_json` shapes are unit-tested
|
|
16
|
+
# elsewhere; THIS proves the task wiring around them (Codex R1, Acceptance
|
|
17
|
+
# Auditor). Mirrors the isolated-Rake harness in
|
|
18
|
+
# spec/ruact/server_functions/rake_spec.rb.
|
|
19
|
+
module Ruact
|
|
20
|
+
RSpec.describe "rake --json introspection tasks", :story_15_3 do
|
|
21
|
+
around do |example|
|
|
22
|
+
Dir.mktmpdir do |dir|
|
|
23
|
+
original_root = Rails.root
|
|
24
|
+
Rails.root = Pathname.new(dir)
|
|
25
|
+
|
|
26
|
+
prev = Rake.application
|
|
27
|
+
Rake.application = Rake::Application.new
|
|
28
|
+
Rake.application.define_task(Rake::Task, :environment)
|
|
29
|
+
load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
|
|
30
|
+
|
|
31
|
+
example.run
|
|
32
|
+
ensure
|
|
33
|
+
Rake.application = prev
|
|
34
|
+
Rails.root = original_root
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def invoke!(task_name)
|
|
39
|
+
Rake::Task[task_name].reenable
|
|
40
|
+
Rake::Task[task_name].invoke
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ---- ruact:doctor -- --json (AC1: JSON-only stdout + preserved exit) ----
|
|
44
|
+
|
|
45
|
+
describe "ruact:doctor -- --json" do
|
|
46
|
+
before { stub_const("ARGV", %w[ruact:doctor -- --json]) }
|
|
47
|
+
|
|
48
|
+
def stub_doctor(status:)
|
|
49
|
+
report = {
|
|
50
|
+
"schema_version" => 0,
|
|
51
|
+
"status" => status,
|
|
52
|
+
"checks" => [{ "name" => "manifest", "status" => status, "message" => "m", "remediation" => nil }]
|
|
53
|
+
}
|
|
54
|
+
allow(Ruact::Doctor).to receive(:new).and_return(instance_double(Ruact::Doctor, as_json: report))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "prints ONLY the JSON document (no prose) and does not exit when all pass", :aggregate_failures do
|
|
58
|
+
stub_doctor(status: "pass")
|
|
59
|
+
|
|
60
|
+
out = nil
|
|
61
|
+
expect { out = capture_stdout_string { invoke!("ruact:doctor") } }.not_to raise_error
|
|
62
|
+
parsed = JSON.parse(out)
|
|
63
|
+
expect(parsed["schema_version"]).to eq(0)
|
|
64
|
+
expect(parsed["status"]).to eq("pass")
|
|
65
|
+
expect(out).not_to include("[ruact] Health check")
|
|
66
|
+
expect(out).not_to include("Run rails generate")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "exits 1 (SystemExit) when a check fails, still emitting the JSON", :aggregate_failures do
|
|
70
|
+
stub_doctor(status: "fail")
|
|
71
|
+
|
|
72
|
+
expect do
|
|
73
|
+
expect { invoke!("ruact:doctor") }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) }
|
|
74
|
+
end.to output(/"status": "fail"/).to_stdout
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "ruact:doctor WITHOUT --json runs the human path (no JSON), returns 0 when healthy" do
|
|
79
|
+
stub_const("ARGV", %w[ruact:doctor])
|
|
80
|
+
allow(Ruact::Doctor).to receive(:run).and_return(true)
|
|
81
|
+
|
|
82
|
+
expect { expect { invoke!("ruact:doctor") }.not_to raise_error }.not_to output(/schema_version/).to_stdout
|
|
83
|
+
expect(Ruact::Doctor).to have_received(:run)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ---- ruact:routes (AC2: JSON via -- --json, human table bare) ----
|
|
87
|
+
|
|
88
|
+
describe "ruact:routes" do
|
|
89
|
+
before do
|
|
90
|
+
stub_const("RakeRoutesPostsController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
91
|
+
reloader = Object.new
|
|
92
|
+
def reloader.execute_unless_loaded; end
|
|
93
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
94
|
+
rs.draw { resources :rake_routes_posts, only: %i[create] }
|
|
95
|
+
app = Object.new
|
|
96
|
+
app.define_singleton_method(:routes) { rs }
|
|
97
|
+
app.define_singleton_method(:routes_reloader) { reloader }
|
|
98
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "emits the EXPERIMENTAL JSON document under -- --json (end-to-end, real resolvers)", :aggregate_failures do
|
|
102
|
+
stub_const("ARGV", %w[ruact:routes -- --json])
|
|
103
|
+
|
|
104
|
+
out = capture_stdout_string { invoke!("ruact:routes") }
|
|
105
|
+
parsed = JSON.parse(out)
|
|
106
|
+
expect(parsed["schema_version"]).to eq(0)
|
|
107
|
+
create = parsed["accessors"].find { |a| a["accessor"] == "createRakeRoutesPost" }
|
|
108
|
+
expect(create).to include("kind" => "action", "verb" => "POST", "path" => "/rake_routes_posts")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "prints a compact human table (no JSON) when invoked bare", :aggregate_failures do
|
|
112
|
+
stub_const("ARGV", %w[ruact:routes])
|
|
113
|
+
|
|
114
|
+
out = capture_stdout_string { invoke!("ruact:routes") }
|
|
115
|
+
expect(out).to include("server-function accessors:")
|
|
116
|
+
expect(out).to include("createRakeRoutesPost")
|
|
117
|
+
expect(out).not_to include("schema_version")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "exits 1 with a `[ruact] error:` line on a naming collision" do
|
|
121
|
+
stub_const("ARGV", %w[ruact:routes -- --json])
|
|
122
|
+
allow(Ruact::ServerFunctions).to receive(:introspect)
|
|
123
|
+
.and_raise(Ruact::ConfigurationError, "server-function naming collision: A#x and B#x ...")
|
|
124
|
+
|
|
125
|
+
expect { invoke!("ruact:routes") }
|
|
126
|
+
.to raise_error(SystemExit)
|
|
127
|
+
.and output(/\[ruact\] error:.*naming collision/).to_stderr
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Captures $stdout into a String (the harness has no shared helper).
|
|
132
|
+
def capture_stdout_string
|
|
133
|
+
original = $stdout
|
|
134
|
+
$stdout = StringIO.new
|
|
135
|
+
yield
|
|
136
|
+
$stdout.string
|
|
137
|
+
ensure
|
|
138
|
+
$stdout = original
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|