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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +9 -0
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +113 -0
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +2 -2
  11. data/lib/ruact/serializable.rb +98 -6
  12. data/lib/ruact/server.rb +163 -0
  13. data/lib/ruact/server_functions/introspection.rb +81 -0
  14. data/lib/ruact/server_functions.rb +26 -4
  15. data/lib/ruact/testing/component_query.rb +113 -0
  16. data/lib/ruact/testing/flight_extractor.rb +221 -0
  17. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  18. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  19. data/lib/ruact/testing.rb +90 -0
  20. data/lib/ruact/version.rb +1 -1
  21. data/lib/tasks/ruact.rake +55 -2
  22. data/spec/ruact/doctor_spec.rb +141 -0
  23. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  24. data/spec/ruact/install_generator_spec.rb +285 -0
  25. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  26. data/spec/ruact/serializable_spec.rb +126 -0
  27. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  28. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  29. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  30. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  31. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  32. data/spec/support/flight_wire_parser.rb +12 -126
  33. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  34. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  35. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  36. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  38. 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