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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- 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 +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -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/spec_helper.rb +15 -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/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- 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
|