ruact 0.0.6 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  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 +18 -3
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +120 -1
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +149 -0
  11. data/lib/ruact/railtie.rb +14 -3
  12. data/lib/ruact/render_pipeline.rb +8 -1
  13. data/lib/ruact/serializable.rb +98 -6
  14. data/lib/ruact/server.rb +163 -0
  15. data/lib/ruact/server_functions/introspection.rb +81 -0
  16. data/lib/ruact/server_functions.rb +26 -4
  17. data/lib/ruact/testing/component_query.rb +113 -0
  18. data/lib/ruact/testing/flight_extractor.rb +221 -0
  19. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  20. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  21. data/lib/ruact/testing.rb +90 -0
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +11 -4
  24. data/lib/ruact.rb +1 -0
  25. data/lib/tasks/ruact.rake +55 -2
  26. data/spec/ruact/controller_spec.rb +7 -2
  27. data/spec/ruact/doctor_spec.rb +141 -0
  28. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  29. data/spec/ruact/install_generator_spec.rb +285 -0
  30. data/spec/ruact/manifest_resolver_spec.rb +174 -0
  31. data/spec/ruact/serializable_spec.rb +126 -0
  32. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  33. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  34. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  35. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  36. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  37. data/spec/spec_helper.rb +15 -0
  38. data/spec/support/flight_wire_parser.rb +12 -126
  39. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  40. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  41. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  42. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  43. data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
  44. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  45. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  46. metadata +17 -2
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "net/http"
5
+ require "tmpdir"
6
+
7
+ module Ruact
8
+ RSpec.describe ManifestResolver do
9
+ # spec_helper pins resolve/resolve_soft to the boot manifest suite-wide for
10
+ # hermeticity; this spec is the one place that exercises the REAL paths.
11
+ before do
12
+ allow(described_class).to receive(:resolve).and_call_original
13
+ allow(described_class).to receive(:resolve_soft).and_call_original
14
+ end
15
+
16
+ let(:manifest_hash) do
17
+ {
18
+ "LikeButton" => {
19
+ "id" => "/LikeButton.jsx",
20
+ "name" => "LikeButton",
21
+ "chunks" => ["/LikeButton.jsx"]
22
+ }
23
+ }
24
+ end
25
+ let(:manifest_json) { JSON.generate(manifest_hash) }
26
+
27
+ # A real Net::HTTPSuccess (HTTPOK < HTTPSuccess) whose body we override, so
28
+ # the resolver's `response.is_a?(Net::HTTPSuccess)` branch is exercised for
29
+ # real without touching the network.
30
+ def http_ok(body)
31
+ response = Net::HTTPOK.new("1.1", "200", "OK")
32
+ response.define_singleton_method(:body) { body }
33
+ response
34
+ end
35
+
36
+ def in_dev
37
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
38
+ end
39
+
40
+ describe ".resolve in development (the boot-race)" do
41
+ before do
42
+ in_dev
43
+ # Simulate the race: boot read the missing file, so the cached manifest is nil.
44
+ allow(Ruact).to receive(:manifest).and_return(nil)
45
+ end
46
+
47
+ it "resolves from the Vite dev server over HTTP (no 500, ref resolvable)" do
48
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
49
+
50
+ manifest = described_class.resolve
51
+
52
+ expect(manifest).to be_a(ClientManifest)
53
+ ref = manifest.reference_for("LikeButton")
54
+ expect(ref.module_id).to eq("/LikeButton.jsx")
55
+ end
56
+
57
+ it "fetches the manifest from the configured vite_dev_server URL" do
58
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
59
+
60
+ described_class.resolve
61
+
62
+ expect(Net::HTTP).to have_received(:start).with(
63
+ "localhost", 5173, hash_including(:open_timeout, :read_timeout)
64
+ )
65
+ end
66
+
67
+ it "does not double-slash the path when vite_dev_server has a trailing slash" do
68
+ allow(described_class).to receive(:base_url).and_return("http://localhost:5173/")
69
+ http = instance_double(Net::HTTP)
70
+ allow(http).to receive(:get).and_return(http_ok(manifest_json))
71
+ allow(Net::HTTP).to receive(:start).and_yield(http).and_return(http_ok(manifest_json))
72
+
73
+ described_class.resolve
74
+
75
+ expect(http).to have_received(:get).with("/__ruact/manifest")
76
+ end
77
+
78
+ it "speaks TLS when vite_dev_server is https" do
79
+ allow(described_class).to receive(:base_url).and_return("https://localhost:5173")
80
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
81
+
82
+ described_class.resolve
83
+
84
+ expect(Net::HTTP).to have_received(:start).with(
85
+ "localhost", 5173, hash_including(use_ssl: true)
86
+ )
87
+ end
88
+
89
+ it "falls back to the on-disk file when the dev server is unreachable" do
90
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
91
+
92
+ Dir.mktmpdir do |dir|
93
+ path = File.join(dir, "react-client-manifest.json")
94
+ File.write(path, manifest_json)
95
+ allow(described_class).to receive(:file_path).and_return(path)
96
+
97
+ manifest = described_class.resolve
98
+ expect(manifest).to be_a(ClientManifest)
99
+ expect(manifest.reference_for("LikeButton").module_id).to eq("/LikeButton.jsx")
100
+ end
101
+ end
102
+
103
+ it "raises a clear, actionable error (not NoMethodError) when neither HTTP nor file is available" do
104
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
105
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
106
+
107
+ expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
108
+ end
109
+
110
+ # Story 15.0 (F8) — an error message is a prompt: agents (and humans)
111
+ # regex-match error text, so the ENGLISH wording and its stable tokens
112
+ # (`Vite dev server`, `react-client-manifest.json`, `bin/dev`) are pinned.
113
+ it "raises the ENGLISH message naming the dev-server URL, the manifest path and the bin/dev fix " \
114
+ "(Story 15.0)", :story_15_0 do
115
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
116
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
117
+
118
+ expect { described_class.resolve }.to raise_error(ManifestError) do |error|
119
+ expect(error.message).to include("[ruact] Vite dev server unreachable at http://localhost:5173")
120
+ expect(error.message).to include("no react-client-manifest.json found at /no/such/manifest.json")
121
+ expect(error.message).to include("run `bin/dev`")
122
+ end
123
+ end
124
+
125
+ it "ignores a non-2xx dev-server response and falls back" do
126
+ allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
127
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
128
+
129
+ expect { described_class.resolve }.to raise_error(ManifestError)
130
+ end
131
+ end
132
+
133
+ describe ".resolve_soft in development (fail-open for FR100)" do
134
+ before do
135
+ in_dev
136
+ allow(Ruact).to receive(:manifest).and_return(nil)
137
+ end
138
+
139
+ it "returns nil (no raise) when nothing is resolvable" do
140
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
141
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
142
+
143
+ expect(described_class.resolve_soft).to be_nil
144
+ end
145
+
146
+ it "returns the dev manifest when the server is up" do
147
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
148
+ expect(described_class.resolve_soft).to be_a(ClientManifest)
149
+ end
150
+ end
151
+
152
+ describe "production / non-development (untouched)" do
153
+ it "returns Ruact.manifest verbatim without any HTTP call" do
154
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
155
+ allow(Net::HTTP).to receive(:start)
156
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
157
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
158
+
159
+ expect(described_class.resolve).to be(boot_manifest)
160
+ expect(Net::HTTP).not_to have_received(:start)
161
+ end
162
+
163
+ it "returns Ruact.manifest in the test environment too (no dev fetch)" do
164
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("test"))
165
+ allow(Net::HTTP).to receive(:start)
166
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
167
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
168
+
169
+ expect(described_class.resolve).to be(boot_manifest)
170
+ expect(Net::HTTP).not_to have_received(:start)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -32,6 +32,132 @@ module Ruact
32
32
  end
33
33
  end
34
34
 
35
+ # Story 13.7 — `ruact_props` on ActiveRecord models.
36
+ #
37
+ # ActiveRecord defines its attribute reader methods LAZILY (on first
38
+ # instance access), so at the moment the `ruact_props :title` macro runs,
39
+ # `method_defined?(:title)` is `false` even for a real column. The eager
40
+ # boot check used to raise there, so an AR model that included
41
+ # `Ruact::Serializable` crashed at class-load. Story 13.7 makes the check
42
+ # HYBRID: POROs are still checked eagerly at class-load (byte-identical, see
43
+ # the block above), while for a lazy-attribute (AR) class the loud check is
44
+ # DEFERRED to the first `ruact_serialize` — where the DB is up and the reader
45
+ # exists. Loudness is preserved: a bogus prop still raises a clean
46
+ # `ArgumentError`, just at first render of that model instead of at boot.
47
+ #
48
+ # Zero suite-boot DB footprint: the connection + table are built INSIDE the
49
+ # example (direct connection DDL, NOT `Schema.define`, so we touch nothing
50
+ # global — no `schema_migrations` / `ar_internal_metadata`), and only a
51
+ # connection we ourselves opened is torn down.
52
+ describe "ActiveRecord models (Story 13.7)", :story_13_7 do
53
+ before do
54
+ require "active_record"
55
+ @already_connected =
56
+ ActiveRecord::Base.respond_to?(:connected?) && ActiveRecord::Base.connected?
57
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") unless @already_connected
58
+ ActiveRecord::Base.connection.create_table(:ruact_props_probe_posts, force: true) do |t|
59
+ t.string :title
60
+ t.string :secret
61
+ end
62
+ end
63
+
64
+ after do
65
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
66
+ ActiveRecord::Base.connection.drop_table(:ruact_props_probe_posts, if_exists: true)
67
+ end
68
+ ActiveRecord::Base.remove_connection unless @already_connected
69
+ end
70
+
71
+ let(:ar_model) do
72
+ Class.new(ActiveRecord::Base) do
73
+ self.table_name = "ruact_props_probe_posts"
74
+ include Ruact::Serializable
75
+
76
+ ruact_props :id, :title
77
+ end
78
+ end
79
+
80
+ it "loads without raising even though readers are lazy at class-load (AC#1)" do
81
+ expect { ar_model }.not_to raise_error
82
+ end
83
+
84
+ it "ruact_serialize returns only the declared allowlist (AC#1)" do
85
+ row = ar_model.create!(title: "Hello", secret: "top-secret")
86
+
87
+ expect(row.ruact_serialize).to eq({ "id" => row.id, "title" => "Hello" })
88
+ expect(row.ruact_serialize.keys).not_to include("secret")
89
+ end
90
+
91
+ it "defers the loud check to first serialize for a bogus prop (AC#2)" do
92
+ model = nil
93
+
94
+ # Declaring a bogus prop on an AR model does NOT raise at class-load —
95
+ # the reader might still be defined lazily, so the check is deferred.
96
+ expect do
97
+ model = Class.new(ActiveRecord::Base) do
98
+ self.table_name = "ruact_props_probe_posts"
99
+ include Ruact::Serializable
100
+
101
+ ruact_props :ttile # typo — neither a column nor a method
102
+ end
103
+ end.not_to raise_error
104
+
105
+ # …but it fails LOUDLY (same clean ArgumentError) on first serialize.
106
+ expect { model.new.ruact_serialize }
107
+ .to raise_error(ArgumentError, /ttile.*not defined/)
108
+ end
109
+
110
+ it "re-validates loudly when props are re-declared after a first serialize (AC#2)" do
111
+ model = Class.new(ActiveRecord::Base) do
112
+ self.table_name = "ruact_props_probe_posts"
113
+ include Ruact::Serializable
114
+
115
+ ruact_props :id, :title
116
+ end
117
+
118
+ # First serialize succeeds and memoizes "validated"…
119
+ model.create!(title: "Hello").ruact_serialize
120
+
121
+ # …a re-declaration with a bogus prop must NOT be masked by the stale
122
+ # memo: the next serialize still raises the clean ArgumentError.
123
+ model.ruact_props :id, :ttile
124
+ expect { model.new.ruact_serialize }
125
+ .to raise_error(ArgumentError, /ttile.*not defined/)
126
+ end
127
+
128
+ it "re-validates an inherited declaration re-declared on the parent after a subclass serialize (AC#2)" do
129
+ parent = Class.new(ActiveRecord::Base) do
130
+ self.table_name = "ruact_props_probe_posts"
131
+ include Ruact::Serializable
132
+
133
+ ruact_props :id, :title
134
+ end
135
+ child = Class.new(parent)
136
+
137
+ # The subclass serializes first, memoizing the inherited (valid) list…
138
+ parent.create!(title: "Hello")
139
+ child.first.ruact_serialize
140
+
141
+ # …then the PARENT re-declares with a bogus prop. The subclass must not
142
+ # keep serving its stale memo: its next serialize re-validates loudly.
143
+ parent.ruact_props :id, :ttile
144
+ expect { child.first.ruact_serialize }
145
+ .to raise_error(ArgumentError, /ttile.*not defined/)
146
+ end
147
+
148
+ it "dispatches through serialize_serializable under strict true AND false (AC#4)" do
149
+ row = ar_model.create!(title: "Hello", secret: "top-secret")
150
+
151
+ [true, false].each do |strict|
152
+ output = Ruact::Flight::Renderer.render(
153
+ row, Ruact::ClientManifest.from_hash({}), strict_serialization: strict
154
+ )
155
+ expect(output).to include("Hello")
156
+ expect(output).not_to include("top-secret")
157
+ end
158
+ end
159
+ end
160
+
35
161
  describe "ruact_serialize (AC#1)" do
36
162
  it "returns only declared props" do
37
163
  obj = serializable_class.new
@@ -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