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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +378 -6
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  7. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  9. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  10. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  11. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  12. data/lib/ruact/configuration.rb +73 -0
  13. data/lib/ruact/controller/document_rendering.rb +210 -0
  14. data/lib/ruact/controller.rb +14 -46
  15. data/lib/ruact/doctor.rb +102 -12
  16. data/lib/ruact/erb_preprocessor.rb +113 -0
  17. data/lib/ruact/errors.rb +16 -0
  18. data/lib/ruact/layout_source.rb +59 -0
  19. data/lib/ruact/manifest_resolver.rb +2 -2
  20. data/lib/ruact/serializable.rb +98 -6
  21. data/lib/ruact/server.rb +163 -0
  22. data/lib/ruact/server_functions/introspection.rb +81 -0
  23. data/lib/ruact/server_functions.rb +26 -4
  24. data/lib/ruact/testing/component_query.rb +113 -0
  25. data/lib/ruact/testing/flight_extractor.rb +221 -0
  26. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  27. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  28. data/lib/ruact/testing.rb +90 -0
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +10 -1
  31. data/lib/ruact.rb +1 -0
  32. data/lib/tasks/ruact.rake +55 -2
  33. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  34. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  35. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  36. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  37. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  38. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  39. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  40. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  41. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  42. data/spec/ruact/controller_request_spec.rb +203 -0
  43. data/spec/ruact/doctor_spec.rb +222 -6
  44. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  45. data/spec/ruact/install_generator_spec.rb +727 -70
  46. data/spec/ruact/layout_source_spec.rb +108 -0
  47. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  48. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  49. data/spec/ruact/serializable_spec.rb +126 -0
  50. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  51. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  52. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  53. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  54. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  55. data/spec/support/flight_wire_parser.rb +12 -126
  56. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  57. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  58. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  59. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  60. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  61. metadata +30 -4
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact"
5
+
6
+ # The single definition of "is this layout migrated?", shared by the runtime and
7
+ # by `ruact:install`. Every example here is a shape that fooled an earlier,
8
+ # looser check — a NAME where only a CALL counts, or a substring where only an
9
+ # attribute counts.
10
+ RSpec.describe Ruact::LayoutSource do
11
+ describe ".wired?" do
12
+ it "accepts a plain output tag" do
13
+ expect(described_class.wired?("<%= ruact_js_assets %>")).to be true
14
+ end
15
+
16
+ it "accepts the raw-output form and tight whitespace" do
17
+ expect(described_class.wired?("<%==ruact_js_assets%>")).to be true
18
+ end
19
+
20
+ it "accepts a call with an argument" do
21
+ expect(described_class.wired?("<%= ruact_js_assets(payload) %>")).to be true
22
+ end
23
+
24
+ # Round-1 finding: a mention in prose read as wired.
25
+ it "rejects a mention inside an ERB comment" do
26
+ expect(described_class.wired?("<%# remember to add ruact_js_assets here %>")).to be false
27
+ end
28
+
29
+ # Round-2 finding, and the sharper version of the same mistake: a genuinely
30
+ # COMMENTED-OUT call. ERB comments do not nest, so this emits nothing — but
31
+ # a regex looking only for `<%=` sees a call and reports the layout ready,
32
+ # which sends the runtime off to render an unmigrated layout.
33
+ it "rejects a commented-out call" do
34
+ expect(described_class.wired?("<%# <%= ruact_js_assets %> %>")).to be false
35
+ end
36
+
37
+ it "rejects a multi-line comment that wraps a call" do
38
+ source = <<~ERB
39
+ <%#
40
+ disabled for now:
41
+ <%= ruact_js_assets %>
42
+ %>
43
+ ERB
44
+ expect(described_class.wired?(source)).to be false
45
+ end
46
+
47
+ it "still sees a real call that follows a comment mentioning it" do
48
+ expect(described_class.wired?("<%# ruact_js_assets %>\n<%= ruact_js_assets %>")).to be true
49
+ end
50
+
51
+ # Round-3 finding: ERB's trim-mode comment (`<%-# ... -%>`) is a comment in
52
+ # Erubi too — verified by compiling it — but the stripper only knew `<%#`,
53
+ # so a call disabled this way read as wired.
54
+ it "rejects a call disabled with a trim-mode comment" do
55
+ expect(described_class.wired?("<%-# <%= ruact_js_assets %> -%>")).to be false
56
+ end
57
+
58
+ # Round-3 finding, the other direction: forbidding `%` before the name to
59
+ # avoid crossing tag boundaries also rejected legitimate calls. A false
60
+ # "unwired" is safer than a false "wired", but it is still a wrong answer —
61
+ # the app would silently keep ruact's CSS-less shell.
62
+ it "accepts a call whose expression contains a percent sign" do
63
+ expect(described_class.wired?(%(<%= raw("100%") + ruact_js_assets %>))).to be true
64
+ expect(described_class.wired?("<%= ruact_js_assets if 50 % 2 == 0 %>")).to be true
65
+ end
66
+
67
+ it "still refuses to match across a tag boundary" do
68
+ expect(described_class.wired?("<%= something %> then a bare ruact_js_assets mention")).to be false
69
+ end
70
+
71
+ it "rejects a layout that never names it" do
72
+ expect(described_class.wired?("<html><body><%= yield %></body></html>")).to be false
73
+ end
74
+ end
75
+
76
+ describe ".root?" do
77
+ it "accepts the emitted form" do
78
+ expect(described_class.root?(%(<div id="root"></div>))).to be true
79
+ end
80
+
81
+ it "accepts single quotes, extra attributes and unquoted values" do
82
+ expect(described_class.root?(%(<div id='root'></div>))).to be true
83
+ expect(described_class.root?(%(<div class="a" id="root" data-x="1"></div>))).to be true
84
+ expect(described_class.root?(%(<div id=root></div>))).to be true
85
+ end
86
+
87
+ # Round-2 finding: `data-id="root"` is not a mount point, and a document
88
+ # carrying one but no real root gives React nothing to mount into.
89
+ it "rejects a look-alike attribute" do
90
+ expect(described_class.root?(%(<body data-id="root"></body>))).to be false
91
+ end
92
+
93
+ it "rejects a different id that merely starts with root" do
94
+ expect(described_class.root?(%(<div id="rootish"></div>))).to be false
95
+ end
96
+ end
97
+
98
+ # The anchor `ruact:install` injects after.
99
+ describe "::ROOT_ELEMENT" do
100
+ it "anchors on a whole empty root div" do
101
+ expect(described_class::ROOT_ELEMENT).to match(%(<div id="root"></div>))
102
+ end
103
+
104
+ it "does not anchor on a look-alike attribute" do
105
+ expect(described_class::ROOT_ELEMENT).not_to match(%(<div data-id="root"></div>))
106
+ end
107
+ end
108
+ end
@@ -107,6 +107,21 @@ module Ruact
107
107
  expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
108
108
  end
109
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
+
110
125
  it "ignores a non-2xx dev-server response and falls back" do
111
126
  allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
112
127
  allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
@@ -1136,6 +1136,20 @@ RSpec.describe Ruact::Generators::ScaffoldGenerator, :story_10_1 do # rubocop:di
1136
1136
  expect(minimal).to eq(%w[button input label badge table alert-dialog dropdown-menu])
1137
1137
  end
1138
1138
 
1139
+ # `ruact:install --shadcn` runs before any resource exists, so it prints
1140
+ # the SUPERSET constant instead of this per-resource list. If a template
1141
+ # ever imports a primitive the constant does not carry, the printed
1142
+ # `npx shadcn add` line would be short one component and the scaffold
1143
+ # would fail its own pre-flight — so pin the relationship.
1144
+ it "keeps the per-resource list a subset of the superset install prints" do
1145
+ superset = described_class::ShadcnPreflight::ALL_SHADCN_COMPONENTS
1146
+ richest = build(%w[Post title:string body:text published:boolean author:references])
1147
+ .required_shadcn_components
1148
+
1149
+ expect(richest).to all(be_in(superset))
1150
+ expect(superset).to match_array(richest)
1151
+ end
1152
+
1139
1153
  it "maps each component to its app/javascript/components/ui/<name>.tsx file" do
1140
1154
  gen = build(default_args)
1141
1155
  expect(gen.shadcn_component_file("table").to_s)
@@ -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