ruact 0.0.1 → 0.0.3
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "active_support/all"
|
|
5
|
+
require "action_dispatch"
|
|
6
|
+
require "ruact/server_functions/route_source"
|
|
7
|
+
|
|
8
|
+
module Ruact
|
|
9
|
+
module ServerFunctions
|
|
10
|
+
# Story 9.3 — route introspection: Rails.application.routes (filtered to
|
|
11
|
+
# non-GET routes on controllers that include Ruact::Server) → v2 mutation
|
|
12
|
+
# entries with the derivation table locked in the 2026-06-09 ADR addendum.
|
|
13
|
+
RSpec.describe RouteSource, :story_9_3 do
|
|
14
|
+
# Builds an isolated RouteSet so specs never depend on a host app's routes.
|
|
15
|
+
def route_set(&blk)
|
|
16
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
17
|
+
rs.draw(&blk)
|
|
18
|
+
rs
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# All controllers count as Ruact::Server hosts (isolates the derivation
|
|
22
|
+
# table from constant-resolution); the filter behaviour is tested
|
|
23
|
+
# separately below.
|
|
24
|
+
def collect(routes, overrides: {})
|
|
25
|
+
described_class.collect(
|
|
26
|
+
routes,
|
|
27
|
+
host_predicate: ->(_controller) { true },
|
|
28
|
+
overrides_for: ->(controller) { overrides.fetch(controller, {}) }
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ids(entries) = entries.map { |e| e["js_identifier"] }
|
|
33
|
+
|
|
34
|
+
describe "derivation table (AC3)" do
|
|
35
|
+
it "names the RESTful collection create as singular verb+resource" do
|
|
36
|
+
rs = route_set { resources :posts, only: %i[create] }
|
|
37
|
+
entry = collect(rs).fetch(0)
|
|
38
|
+
expect(entry["js_identifier"]).to eq("createPost")
|
|
39
|
+
expect(entry["http_method"]).to eq("POST")
|
|
40
|
+
expect(entry["path"]).to eq("/posts")
|
|
41
|
+
expect(entry["segments"]).to eq([])
|
|
42
|
+
expect(entry["kind"]).to eq("action")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "names update (member) singular and prefers PATCH over the PUT alias" do
|
|
46
|
+
rs = route_set { resources :posts, only: %i[update] }
|
|
47
|
+
entries = collect(rs)
|
|
48
|
+
expect(entries.size).to eq(1) # PATCH + PUT collapse to one entry
|
|
49
|
+
entry = entries.fetch(0)
|
|
50
|
+
expect(entry["js_identifier"]).to eq("updatePost")
|
|
51
|
+
expect(entry["http_method"]).to eq("PATCH")
|
|
52
|
+
expect(entry["path"]).to eq("/posts/:id")
|
|
53
|
+
expect(entry["segments"]).to eq(["id"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "names destroy (member) singular" do
|
|
57
|
+
rs = route_set { resources :posts, only: %i[destroy] }
|
|
58
|
+
entry = collect(rs).fetch(0)
|
|
59
|
+
expect(entry["js_identifier"]).to eq("destroyPost")
|
|
60
|
+
expect(entry["http_method"]).to eq("DELETE")
|
|
61
|
+
expect(entry["path"]).to eq("/posts/:id")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "skips the GET RESTful actions (index/show/new/edit)" do
|
|
65
|
+
rs = route_set { resources :posts }
|
|
66
|
+
expect(ids(collect(rs))).to match_array(%w[createPost updatePost destroyPost])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "names a custom member route singular (verb-action + singular resource)" do
|
|
70
|
+
rs = route_set do
|
|
71
|
+
resources :posts do
|
|
72
|
+
member { post :publish }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
entry = collect(rs).find { |e| e["js_identifier"] == "publishPost" }
|
|
76
|
+
expect(entry).not_to be_nil
|
|
77
|
+
expect(entry["http_method"]).to eq("POST")
|
|
78
|
+
expect(entry["path"]).to eq("/posts/:id/publish")
|
|
79
|
+
expect(entry["segments"]).to eq(["id"])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "names a custom collection route plural" do
|
|
83
|
+
rs = route_set do
|
|
84
|
+
resources :posts do
|
|
85
|
+
collection { post :publish_all }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
entry = collect(rs).find { |e| e["js_identifier"] == "publishAllPosts" }
|
|
89
|
+
expect(entry).not_to be_nil
|
|
90
|
+
expect(entry["path"]).to eq("/posts/publish_all")
|
|
91
|
+
expect(entry["segments"]).to eq([])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "names singular-resource create/destroy singular" do
|
|
95
|
+
rs = route_set { resource :session, only: %i[create destroy] }
|
|
96
|
+
expect(ids(collect(rs))).to match_array(%w[createSession destroySession])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "prefixes namespaced controllers (verb + Namespace + Resource)" do
|
|
100
|
+
rs = route_set { namespace(:admin) { resources :posts, only: %i[create update] } }
|
|
101
|
+
expect(ids(collect(rs))).to match_array(%w[createAdminPost updateAdminPost])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "deep-prefixes multi-level namespaces" do
|
|
105
|
+
rs = route_set do
|
|
106
|
+
namespace(:admin) { namespace(:reports) { resources :posts, only: %i[create] } }
|
|
107
|
+
end
|
|
108
|
+
expect(ids(collect(rs))).to eq(%w[createAdminReportsPost])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "sorts entries by js_identifier for deterministic output" do
|
|
112
|
+
rs = route_set { resources :posts }
|
|
113
|
+
expect(ids(collect(rs))).to eq(%w[createPost destroyPost updatePost])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "classifies a custom-param member route (param: :slug) as member → singular" do
|
|
117
|
+
rs = route_set do
|
|
118
|
+
resources :posts, param: :slug do
|
|
119
|
+
member { post :publish }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
entry = collect(rs).find { |e| e["action"] == "publish" }
|
|
123
|
+
expect(entry["js_identifier"]).to eq("publishPost")
|
|
124
|
+
expect(entry["segments"]).to eq(["slug"])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "classifies a nested collection route (only parent :id present) as collection → plural" do
|
|
128
|
+
rs = route_set do
|
|
129
|
+
resources :posts, only: [] do
|
|
130
|
+
resources :comments, only: [] do
|
|
131
|
+
collection { post :flag_all }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
entry = collect(rs).find { |e| e["action"] == "flag_all" }
|
|
136
|
+
expect(entry["js_identifier"]).to eq("flagAllComments")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "rename override (AC4 input)" do
|
|
141
|
+
it "uses the per-controller override identifier when present" do
|
|
142
|
+
rs = route_set do
|
|
143
|
+
resources :posts do
|
|
144
|
+
collection { post :publish_all }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
entries = collect(rs, overrides: { "posts" => { "publish_all" => "publishEverything" } })
|
|
148
|
+
expect(ids(entries)).to include("publishEverything")
|
|
149
|
+
expect(ids(entries)).not_to include("publishAllPosts")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "collision detection (AC4)" do
|
|
154
|
+
it "fails loudly naming BOTH origins when two routes map to the same identifier" do
|
|
155
|
+
rs = route_set do
|
|
156
|
+
resources :posts, only: %i[create]
|
|
157
|
+
resources :comments, only: %i[create]
|
|
158
|
+
end
|
|
159
|
+
# Force a collision: comments#create is renamed onto posts#create's name.
|
|
160
|
+
overrides = { "comments" => { "create" => "createPost" } }
|
|
161
|
+
expect do
|
|
162
|
+
collect(rs, overrides: overrides)
|
|
163
|
+
end.to raise_error(Ruact::ConfigurationError, /naming collision/) { |err|
|
|
164
|
+
expect(err.message).to include("posts#create")
|
|
165
|
+
expect(err.message).to include("comments#create")
|
|
166
|
+
expect(err.message).to include("createPost")
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "boot succeeds once the colliding route is renamed to a free identifier (full cycle)" do
|
|
171
|
+
rs = route_set do
|
|
172
|
+
resources :posts, only: %i[create]
|
|
173
|
+
resources :comments, only: %i[create]
|
|
174
|
+
end
|
|
175
|
+
overrides = { "comments" => { "create" => "createComment" } }
|
|
176
|
+
ids_out = ids(collect(rs, overrides: overrides))
|
|
177
|
+
expect(ids_out).to eq(%w[createComment createPost])
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
describe "host filter (Ruact::Server only)" do
|
|
182
|
+
it "skips routes whose controller is not a Ruact::Server host" do
|
|
183
|
+
rs = route_set do
|
|
184
|
+
resources :posts, only: %i[create]
|
|
185
|
+
resources :widgets, only: %i[create]
|
|
186
|
+
end
|
|
187
|
+
entries = described_class.collect(
|
|
188
|
+
rs,
|
|
189
|
+
host_predicate: ->(controller) { controller == "posts" },
|
|
190
|
+
overrides_for: ->(_c) { {} }
|
|
191
|
+
)
|
|
192
|
+
expect(ids(entries)).to eq(%w[createPost])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "skips GET/HEAD routes even on a host controller" do
|
|
196
|
+
rs = route_set { get "/posts/search", to: "posts#search" }
|
|
197
|
+
expect(collect(rs)).to be_empty
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
RSpec.describe Snapshot, :story_8_0a do
|
|
9
|
+
let(:posts_controller) { Class.new { def self.name = "PostsController" } }
|
|
10
|
+
let(:cats_controller) { Class.new { def self.name = "CategoriesController" } }
|
|
11
|
+
let(:actions) { Registry.new }
|
|
12
|
+
let(:queries) { Registry.new }
|
|
13
|
+
let(:frozen_time) { Time.utc(2026, 5, 13, 12, 34, 56) }
|
|
14
|
+
|
|
15
|
+
describe ".dump (Story 8.0a — pure snapshot builder)" do
|
|
16
|
+
it "returns the empty payload when both registries are empty" do
|
|
17
|
+
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
18
|
+
|
|
19
|
+
expect(snapshot).to eq(
|
|
20
|
+
version: 1,
|
|
21
|
+
generated_at: "2026-05-13T12:34:56Z",
|
|
22
|
+
functions: []
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "merges action + query entries into a single functions array" do
|
|
27
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
28
|
+
queries.register(:categories, kind: :query, controller: cats_controller)
|
|
29
|
+
|
|
30
|
+
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
31
|
+
|
|
32
|
+
expect(snapshot[:functions]).to contain_exactly(
|
|
33
|
+
{ "ruby_symbol" => "create_post", "js_identifier" => "createPost",
|
|
34
|
+
"kind" => "action", "controller" => "PostsController" },
|
|
35
|
+
{ "ruby_symbol" => "categories", "js_identifier" => "categories",
|
|
36
|
+
"kind" => "query", "controller" => "CategoriesController" }
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "sorts functions by ruby_symbol for deterministic output (Story 8.0a)" do
|
|
41
|
+
actions.register(:zeta, kind: :action, controller: posts_controller)
|
|
42
|
+
actions.register(:alpha, kind: :action, controller: posts_controller)
|
|
43
|
+
queries.register(:mike, kind: :query, controller: posts_controller)
|
|
44
|
+
|
|
45
|
+
symbols = described_class.dump(actions, queries, now: frozen_time)[:functions]
|
|
46
|
+
.map { |fn| fn["ruby_symbol"] }
|
|
47
|
+
expect(symbols).to eq(%w[alpha mike zeta])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "stringifies controller class names; falls back to nil for nil controllers" do
|
|
51
|
+
actions.register(:demo_ping, kind: :action, controller: nil)
|
|
52
|
+
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
53
|
+
expect(snapshot[:functions].first["controller"]).to be_nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe ".functions_payload — cross-registry collision (Chunk1 Blocker 2026-05-13)" do
|
|
58
|
+
it "raises Ruact::ConfigurationError shaped per AC7 when an action and a query " \
|
|
59
|
+
"share a JS identifier (Re-run patch 2026-05-14 — message prefix aligned with " \
|
|
60
|
+
"within-registry collision)",
|
|
61
|
+
:aggregate_failures do
|
|
62
|
+
actions.register(:foo, kind: :action, controller: posts_controller)
|
|
63
|
+
queries.register(:foo, kind: :query, controller: cats_controller)
|
|
64
|
+
expect { described_class.functions_payload(actions, queries) }
|
|
65
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
66
|
+
# AC7 exact shape: rake wraps to "[ruact] error: server-function naming collision: :foo (in PostsController) and :foo (in CategoriesController) both map to JS identifier \"foo\""
|
|
67
|
+
expect(error.message).to start_with("server-function naming collision:")
|
|
68
|
+
expect(error.message).to include(":foo (in PostsController)")
|
|
69
|
+
expect(error.message).to include(":foo (in CategoriesController)")
|
|
70
|
+
expect(error.message).to include('"foo"')
|
|
71
|
+
# No "kind:" annotation per Pass-2 patch 2026-05-14
|
|
72
|
+
expect(error.message).not_to include("kind:")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "raises when different Ruby symbols cross-collide via the naming bridge" do
|
|
77
|
+
# `:foo_bar` action + `:foo__bar` query both → "fooBar"
|
|
78
|
+
actions.register(:foo_bar, kind: :action, controller: posts_controller)
|
|
79
|
+
queries.register(:foo__bar, kind: :query, controller: cats_controller)
|
|
80
|
+
expect { described_class.functions_payload(actions, queries) }
|
|
81
|
+
.to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "raises when both registries contain the same Ruby symbol with matching " \
|
|
85
|
+
"js_identifier (one js_identifier per emitted export is the design intent)" do
|
|
86
|
+
actions.register(:categories, kind: :action, controller: posts_controller)
|
|
87
|
+
queries.register(:categories, kind: :query, controller: cats_controller)
|
|
88
|
+
expect { described_class.functions_payload(actions, queries) }
|
|
89
|
+
.to raise_error(Ruact::ConfigurationError, /server-function naming collision/)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe ".functions_payload (Story 8.0a — fingerprint surface)" do
|
|
94
|
+
it "excludes the generated_at timestamp so registry-equivalent calls match" do
|
|
95
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
96
|
+
|
|
97
|
+
first = described_class.functions_payload(actions, queries)
|
|
98
|
+
sleep 0.01
|
|
99
|
+
second = described_class.functions_payload(actions, queries)
|
|
100
|
+
|
|
101
|
+
expect(first).to eq(second)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe ".generate! (Story 8.0a — write-if-changed orchestration)" do
|
|
106
|
+
around do |example|
|
|
107
|
+
Dir.mktmpdir do |dir|
|
|
108
|
+
@tmpdir = dir
|
|
109
|
+
example.run
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
let(:path) { File.join(@tmpdir, "server-functions.json") }
|
|
114
|
+
|
|
115
|
+
it "writes the file on first call and returns true" do
|
|
116
|
+
result = described_class.generate!(
|
|
117
|
+
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(result).to be(true)
|
|
121
|
+
expect(File).to exist(path)
|
|
122
|
+
parsed = JSON.parse(File.read(path))
|
|
123
|
+
expect(parsed.fetch("version")).to eq(1)
|
|
124
|
+
expect(parsed.fetch("functions")).to eq([])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "does NOT rewrite the file when the registry is unchanged " \
|
|
128
|
+
"(Story 8.0a — pitfall #1 mitigation)", :aggregate_failures do
|
|
129
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
130
|
+
described_class.generate!(action_registry: actions, query_registry: queries,
|
|
131
|
+
path: path, now: frozen_time)
|
|
132
|
+
|
|
133
|
+
original_mtime = File.mtime(path)
|
|
134
|
+
original_bytes = File.read(path)
|
|
135
|
+
original_time = JSON.parse(original_bytes)["generated_at"]
|
|
136
|
+
sleep 1.05 # ensure mtime resolution is exceeded if we DID rewrite
|
|
137
|
+
|
|
138
|
+
result = described_class.generate!(
|
|
139
|
+
action_registry: actions, query_registry: queries,
|
|
140
|
+
path: path, now: Time.now.utc # different now
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(result).to be(false)
|
|
144
|
+
expect(File.mtime(path)).to eq(original_mtime)
|
|
145
|
+
expect(JSON.parse(File.read(path))["generated_at"]).to eq(original_time)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "rewrites the file when a function is added" do
|
|
149
|
+
described_class.generate!(action_registry: actions, query_registry: queries,
|
|
150
|
+
path: path, now: frozen_time)
|
|
151
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
152
|
+
|
|
153
|
+
result = described_class.generate!(
|
|
154
|
+
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(result).to be(true)
|
|
158
|
+
expect(JSON.parse(File.read(path))["functions"].size).to eq(1)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "creates the parent directory if missing" do
|
|
162
|
+
nested = File.join(@tmpdir, "deep", "nest", "server-functions.json")
|
|
163
|
+
expect do
|
|
164
|
+
described_class.generate!(action_registry: actions, query_registry: queries,
|
|
165
|
+
path: nested, now: frozen_time)
|
|
166
|
+
end
|
|
167
|
+
.to change { File.exist?(nested) }.from(false).to(true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "recovers from a corrupted existing file by overwriting it" do
|
|
171
|
+
File.write(path, "not json")
|
|
172
|
+
result = described_class.generate!(
|
|
173
|
+
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
174
|
+
)
|
|
175
|
+
expect(result).to be(true)
|
|
176
|
+
expect { JSON.parse(File.read(path)) }.not_to raise_error
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "rewrites the file when the on-disk snapshot has a different version " \
|
|
180
|
+
"(Chunk1 Major 2026-05-13 — version mismatch must not be treated as unchanged)" do
|
|
181
|
+
File.write(path, JSON.pretty_generate(version: 99, generated_at: "2020-01-01T00:00:00Z", functions: []))
|
|
182
|
+
result = described_class.generate!(
|
|
183
|
+
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
184
|
+
)
|
|
185
|
+
expect(result).to be(true)
|
|
186
|
+
expect(JSON.parse(File.read(path))["version"]).to eq(1)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe "route-driven (v2) snapshot (Story 9.3)", :story_9_3 do
|
|
191
|
+
let(:entries) do
|
|
192
|
+
[
|
|
193
|
+
{ "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
|
|
194
|
+
"path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
|
|
195
|
+
]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
describe ".dump_v2" do
|
|
199
|
+
it "wraps entries in a version-2 snapshot Hash" do
|
|
200
|
+
snap = described_class.dump_v2(entries, now: frozen_time)
|
|
201
|
+
expect(snap[:version]).to eq(2)
|
|
202
|
+
expect(snap[:generated_at]).to eq(frozen_time.iso8601)
|
|
203
|
+
expect(snap[:functions]).to eq(entries)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe ".generate_v2! (write-if-changed)" do
|
|
208
|
+
around do |example|
|
|
209
|
+
Dir.mktmpdir do |dir|
|
|
210
|
+
@tmpdir = dir
|
|
211
|
+
example.run
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
let(:path) { File.join(@tmpdir, "server-functions.next.json") }
|
|
216
|
+
|
|
217
|
+
it "writes a version-2 bridge on first call" do
|
|
218
|
+
expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
|
|
219
|
+
parsed = JSON.parse(File.read(path))
|
|
220
|
+
expect(parsed.fetch("version")).to eq(2)
|
|
221
|
+
expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "short-circuits when entries are unchanged (no timestamp churn)" do
|
|
225
|
+
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
226
|
+
expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "rewrites when entries change" do
|
|
230
|
+
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
231
|
+
more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
|
|
232
|
+
"http_method" => "DELETE", "path" => "/posts/:id",
|
|
233
|
+
"segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
|
|
234
|
+
expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
describe ".v1_declarations? (Decision-#6 ownership primitive)" do
|
|
239
|
+
it "is false when both registries are empty" do
|
|
240
|
+
expect(described_class.v1_declarations?(Registry.new, Registry.new)).to be(false)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "is true when the action registry has any entry" do
|
|
244
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
245
|
+
expect(described_class.v1_declarations?(actions, Registry.new)).to be(true)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "is true when the query registry has any entry" do
|
|
249
|
+
queries.register(:categories, kind: :query, controller: cats_controller)
|
|
250
|
+
expect(described_class.v1_declarations?(Registry.new, queries)).to be(true)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
RSpec.describe SnapshotWriter, :story_8_0a do
|
|
9
|
+
around do |example|
|
|
10
|
+
Dir.mktmpdir do |dir|
|
|
11
|
+
@tmpdir = dir
|
|
12
|
+
example.run
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:path) { File.join(@tmpdir, "out.txt") }
|
|
17
|
+
|
|
18
|
+
describe ".write_if_changed! (Story 8.0a — atomic, byte-aware writer)" do
|
|
19
|
+
it "writes the file when it does not yet exist and returns true" do
|
|
20
|
+
expect(described_class.write_if_changed!(path: path, content: "hello\n"))
|
|
21
|
+
.to be(true)
|
|
22
|
+
expect(File.read(path)).to eq("hello\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "skips writing when the existing content matches byte-for-byte" do
|
|
26
|
+
File.write(path, "hello\n")
|
|
27
|
+
original_mtime = File.mtime(path)
|
|
28
|
+
sleep 1.05
|
|
29
|
+
|
|
30
|
+
expect(described_class.write_if_changed!(path: path, content: "hello\n"))
|
|
31
|
+
.to be(false)
|
|
32
|
+
expect(File.mtime(path)).to eq(original_mtime)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "writes when the existing content differs by even a single byte" do
|
|
36
|
+
File.write(path, "hello\n")
|
|
37
|
+
expect(described_class.write_if_changed!(path: path, content: "hello!\n"))
|
|
38
|
+
.to be(true)
|
|
39
|
+
expect(File.read(path)).to eq("hello!\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "creates missing parent directories" do
|
|
43
|
+
nested = File.join(@tmpdir, "a", "b", "c", "out.txt")
|
|
44
|
+
described_class.write_if_changed!(path: nested, content: "x")
|
|
45
|
+
expect(File.read(nested)).to eq("x")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "writes via a same-directory tmpfile so partial reads never see " \
|
|
49
|
+
"a torn file (Story 8.0a)" do
|
|
50
|
+
described_class.write_if_changed!(path: path, content: "atomic\n")
|
|
51
|
+
# After the write the temp sibling must not linger.
|
|
52
|
+
siblings = Dir.children(@tmpdir)
|
|
53
|
+
expect(siblings).to eq(["out.txt"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "raises Ruact::ConfigurationError when the parent directory is unwritable " \
|
|
57
|
+
"(Story 8.0a)" do
|
|
58
|
+
read_only = File.join(@tmpdir, "ro")
|
|
59
|
+
FileUtils.mkdir_p(read_only)
|
|
60
|
+
FileUtils.chmod(0o500, read_only)
|
|
61
|
+
target = File.join(read_only, "nested", "out.txt")
|
|
62
|
+
|
|
63
|
+
expect { described_class.write_if_changed!(path: target, content: "x") }
|
|
64
|
+
.to raise_error(Ruact::ConfigurationError, /cannot create/)
|
|
65
|
+
ensure
|
|
66
|
+
FileUtils.chmod(0o700, read_only) if read_only && File.exist?(read_only)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|