ruact 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +378 -6
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +14 -46
- data/lib/ruact/doctor.rb +102 -12
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +222 -6
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +727 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +30 -4
|
@@ -0,0 +1,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
|