ruact 0.0.2 → 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.
Files changed (128) 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 +86 -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 +1680 -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 +89 -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 +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. 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