ruact 0.0.2 → 0.0.4
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 +88 -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 +1779 -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 +100 -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 +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -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 +111 -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 +598 -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 +508 -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 +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -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 +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- 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 +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module ServerFunctions
|
|
7
|
+
RSpec.describe Registry, :story_8_0a do
|
|
8
|
+
subject(:registry) { described_class.new }
|
|
9
|
+
|
|
10
|
+
let(:posts_controller) do
|
|
11
|
+
Class.new { def self.name = "PostsController" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:bar_controller) do
|
|
15
|
+
Class.new { def self.name = "BarController" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "#register (Story 8.0a)" do
|
|
19
|
+
it "stores a single entry keyed by its Ruby symbol", :aggregate_failures do
|
|
20
|
+
entry = registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
21
|
+
|
|
22
|
+
expect(entry).to be_a(RegistryEntry)
|
|
23
|
+
expect(entry.ruby_symbol).to eq(:create_post)
|
|
24
|
+
expect(entry.js_identifier).to eq("createPost")
|
|
25
|
+
expect(entry.kind).to eq(:action)
|
|
26
|
+
expect(entry.controller).to eq(posts_controller)
|
|
27
|
+
expect(registry.entries.keys).to eq([:create_post])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "captures the implementation block verbatim for downstream invocation" do
|
|
31
|
+
block = -> { :pong }
|
|
32
|
+
entry = registry.register(:demo_ping, kind: :action, controller: posts_controller, &block)
|
|
33
|
+
expect(entry.block).to be(block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "allows registering an action and a query with the same symbol in separate registries" do
|
|
37
|
+
actions = described_class.new
|
|
38
|
+
queries = described_class.new
|
|
39
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
40
|
+
queries.register(:create_post, kind: :query, controller: posts_controller)
|
|
41
|
+
expect(actions.entries.keys).to eq([:create_post])
|
|
42
|
+
expect(queries.entries.keys).to eq([:create_post])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "raises Ruact::ConfigurationError for SCREAMING_SNAKE symbols (Story 8.0a)" do
|
|
46
|
+
expect { registry.register(:RECALCULATE, kind: :action, controller: posts_controller) }
|
|
47
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
48
|
+
expect(error.message).to include(":RECALCULATE")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises Ruact::ConfigurationError on JS-identifier collision and names both " \
|
|
53
|
+
"Ruby symbols and both controllers (Story 8.0a)", :aggregate_failures do
|
|
54
|
+
registry.register(:foo_bar, kind: :action, controller: posts_controller)
|
|
55
|
+
expect { registry.register(:foo__bar, kind: :action, controller: bar_controller) }
|
|
56
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
57
|
+
expect(error.message).to include(":foo_bar")
|
|
58
|
+
expect(error.message).to include(":foo__bar")
|
|
59
|
+
expect(error.message).to include("PostsController")
|
|
60
|
+
expect(error.message).to include("BarController")
|
|
61
|
+
expect(error.message).to include('"fooBar"')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "allows re-registering the same Ruby symbol (replace semantics, dev reload)" do
|
|
66
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
67
|
+
expect do
|
|
68
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
69
|
+
end.not_to raise_error
|
|
70
|
+
expect(registry.size).to eq(1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "tolerates a nil controller (Rails-console registration path)" do
|
|
74
|
+
expect { registry.register(:create_post, kind: :action) }.not_to raise_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects kinds other than :action / :query (Chunk1 Major 2026-05-13)" do
|
|
78
|
+
expect { registry.register(:create_post, kind: :wat, controller: posts_controller) }
|
|
79
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
80
|
+
expect(error.message).to include(":create_post")
|
|
81
|
+
expect(error.message).to include("PostsController")
|
|
82
|
+
expect(error.message).to include(":wat")
|
|
83
|
+
expect(error.message).to include("[:action, :query]")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "wraps NameBridge symbol-shape failures with AC7 'invalid server-function " \
|
|
88
|
+
"symbol :SYMBOL in CONTROLLER' framing (Re-run patch m5)" do
|
|
89
|
+
expect { registry.register(:RECALCULATE, kind: :action, controller: posts_controller) }
|
|
90
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
91
|
+
expect(error.message).to start_with("invalid server-function symbol :RECALCULATE in PostsController")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe "#entries (Story 8.0a)" do
|
|
97
|
+
it "returns a frozen snapshot independent of subsequent mutations" do
|
|
98
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
99
|
+
snapshot = registry.entries
|
|
100
|
+
expect(snapshot).to be_frozen
|
|
101
|
+
registry.register(:list_posts, kind: :query, controller: posts_controller)
|
|
102
|
+
expect(snapshot.keys).to eq([:create_post])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "#clear! (Story 8.0a)" do
|
|
107
|
+
it "wipes all entries and returns self" do
|
|
108
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
109
|
+
expect(registry.clear!).to be(registry)
|
|
110
|
+
expect(registry).to be_empty
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe "Story 8.3 — mixed controller+standalone collision", :story_8_3 do
|
|
115
|
+
let(:posts_controller_class) do
|
|
116
|
+
Class.new { def self.name = "PostsController" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
let(:standalone_create_post_module) do
|
|
120
|
+
Module.new do
|
|
121
|
+
extend Ruact::ServerAction
|
|
122
|
+
|
|
123
|
+
def self.name
|
|
124
|
+
"CreatePost"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "raises Ruact::ConfigurationError when the same Ruby symbol is declared in a controller " \
|
|
130
|
+
"AND in a standalone module — message names BOTH hosts" do
|
|
131
|
+
registry.register(:create_post, kind: :action, controller: posts_controller_class)
|
|
132
|
+
|
|
133
|
+
expect do
|
|
134
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
135
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
136
|
+
expect(error.message).to include(":create_post")
|
|
137
|
+
expect(error.message).to include("PostsController")
|
|
138
|
+
expect(error.message).to include("CreatePost")
|
|
139
|
+
expect(error.message).to include("declared in BOTH")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "raises Ruact::ConfigurationError when the same symbol is declared in standalone " \
|
|
144
|
+
"first, then in a controller (order-independent)" do
|
|
145
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
146
|
+
|
|
147
|
+
expect do
|
|
148
|
+
registry.register(:create_post, kind: :action, controller: posts_controller_class)
|
|
149
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
150
|
+
expect(error.message).to include("PostsController")
|
|
151
|
+
expect(error.message).to include("CreatePost")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "describe_controller names a Module host correctly (no inspection fallback) " \
|
|
156
|
+
"when one side of the collision is a Module" do
|
|
157
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
158
|
+
another_module = Module.new do
|
|
159
|
+
extend Ruact::ServerAction
|
|
160
|
+
|
|
161
|
+
def self.name
|
|
162
|
+
"AdminCreatePost"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Cross-bridge JS-identifier collision: two DIFFERENT Ruby symbols
|
|
167
|
+
# producing the SAME JS identifier — bridges into `js_identifier ==`
|
|
168
|
+
# branch of detect_collision!. The bridge collapses underscores,
|
|
169
|
+
# so `:create_post` and `:create__post` both → "createPost".
|
|
170
|
+
expect do
|
|
171
|
+
registry.register(:create__post, kind: :action, controller: another_module)
|
|
172
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
173
|
+
expect(error.message).to include("CreatePost")
|
|
174
|
+
expect(error.message).to include("AdminCreatePost")
|
|
175
|
+
expect(error.message).to include('"createPost"')
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "Ruact module-level accessors (Story 8.0a)" do
|
|
181
|
+
it "returns two independent Registry singletons" do
|
|
182
|
+
expect(Ruact.action_registry).to be_a(described_class)
|
|
183
|
+
expect(Ruact.query_registry).to be_a(described_class)
|
|
184
|
+
expect(Ruact.action_registry).not_to equal(Ruact.query_registry)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "memoizes the same instance across calls" do
|
|
188
|
+
expect(Ruact.action_registry).to equal(Ruact.action_registry)
|
|
189
|
+
expect(Ruact.query_registry).to equal(Ruact.query_registry)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "both registries are empty at boot (Story 8.1 / 9.1 populate them)" do
|
|
193
|
+
expect(Ruact.action_registry).to be_empty
|
|
194
|
+
expect(Ruact.query_registry).to be_empty
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -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
|