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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. 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