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,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ RSpec.describe Codegen, :story_8_0a do
9
+ let(:base_snapshot) do
10
+ {
11
+ version: 1,
12
+ generated_at: "2026-05-13T12:34:56Z",
13
+ functions: []
14
+ }
15
+ end
16
+
17
+ describe ".render — empty registry (Story 8.0a AC4)" do
18
+ it "emits a valid module with import line and the empty-registry comment" do
19
+ # Story 8.2 — the empty-registry branch also emits the
20
+ # `export { revalidate } from "..."` re-export so
21
+ # `import { revalidate } from "@/.ruact/server-functions"` works in
22
+ # projects that have not yet declared any server actions.
23
+ result = described_class.render(base_snapshot)
24
+
25
+ expect(result).to eq(<<~TS)
26
+ // AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
27
+ // Source: tmp/cache/ruact/server-functions.json (version 1)
28
+ // Generated at: 2026-05-13T12:34:56Z
29
+ import { _makeRef } from "ruact/server-functions-runtime";
30
+
31
+ // (no server functions registered yet — Stories 8.1 / 9.1 populate)
32
+ void _makeRef;
33
+
34
+ export { revalidate } from "ruact/server-functions-runtime";
35
+ TS
36
+ end
37
+
38
+ it "ends with exactly one trailing newline" do
39
+ result = described_class.render(base_snapshot)
40
+ expect(result).to end_with("\n")
41
+ expect(result).not_to end_with("\n\n")
42
+ end
43
+
44
+ it "references _makeRef even when empty so noUnusedLocals stays green " \
45
+ "(Re-run patch 2026-05-13)" do
46
+ # Strictly checks that the import is "touched" — the canonical
47
+ # `void _makeRef;` is the lightweight discard pattern.
48
+ expect(described_class.render(base_snapshot)).to include("void _makeRef;")
49
+ end
50
+ end
51
+
52
+ describe ".render — single action (Story 8.0a AC4 + Story 8.2 intersection)" do
53
+ it "emits an action export typed as a TS intersection of direct-call + <form action> shapes" do
54
+ # Story 8.2 (refined 2026-05-17 per review patch R1) — the
55
+ # intersection makes `<form action={createPost}>` typecheck
56
+ # directly against React 19's
57
+ # `(formData: FormData) => void | Promise<void>` while preserving
58
+ # `Promise<unknown>` for direct callers. See the 2026-05-17 entry
59
+ # in `gem/docs/internal/decisions/server-functions-api.md`.
60
+ snapshot = base_snapshot.merge(functions: [
61
+ {
62
+ "ruby_symbol" => "create_post",
63
+ "js_identifier" => "createPost",
64
+ "kind" => "action",
65
+ "controller" => "PostsController"
66
+ }
67
+ ])
68
+
69
+ expect(described_class.render(snapshot)).to eq(<<~TS)
70
+ // AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
71
+ // Source: tmp/cache/ruact/server-functions.json (version 1)
72
+ // Generated at: 2026-05-13T12:34:56Z
73
+ import { _makeRef } from "ruact/server-functions-runtime";
74
+
75
+ export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =
76
+ _makeRef("create_post");
77
+
78
+ export { revalidate } from "ruact/server-functions-runtime";
79
+ TS
80
+ end
81
+
82
+ it "Story 8.2 — query signatures do NOT widen (regression guard)" do
83
+ snapshot = base_snapshot.merge(functions: [
84
+ {
85
+ "ruby_symbol" => "categories",
86
+ "js_identifier" => "categories",
87
+ "kind" => "query",
88
+ "controller" => "CategoriesController"
89
+ }
90
+ ])
91
+
92
+ out = described_class.render(snapshot)
93
+ # Action-style FormData widening must not bleed into the query export
94
+ expect(out).not_to match(/export const categories:[^=]*FormData/)
95
+ expect(out).to include("export const categories: () => Promise<unknown> =")
96
+ end
97
+ end
98
+
99
+ describe ".render — single query (Story 8.0a AC4)" do
100
+ it "emits a query export with the no-args signature" do
101
+ snapshot = base_snapshot.merge(functions: [
102
+ {
103
+ "ruby_symbol" => "categories",
104
+ "js_identifier" => "categories",
105
+ "kind" => "query",
106
+ "controller" => "CategoriesController"
107
+ }
108
+ ])
109
+
110
+ expect(described_class.render(snapshot)).to include(
111
+ "export const categories: () => Promise<unknown> =\n _makeRef(\"categories\");\n"
112
+ )
113
+ end
114
+ end
115
+
116
+ describe ".render — mixed action and query (Story 8.0a)" do
117
+ it "emits both shapes in the order provided by the snapshot" do
118
+ snapshot = base_snapshot.merge(functions: [
119
+ { "ruby_symbol" => "create_post",
120
+ "js_identifier" => "createPost",
121
+ "kind" => "action",
122
+ "controller" => "PostsController" },
123
+ { "ruby_symbol" => "categories",
124
+ "js_identifier" => "categories",
125
+ "kind" => "query",
126
+ "controller" => "CategoriesController" }
127
+ ])
128
+
129
+ out = described_class.render(snapshot)
130
+ expect(out).to include("export const createPost:")
131
+ expect(out).to include("export const categories:")
132
+ expect(out.index("createPost")).to be < out.index("categories")
133
+ end
134
+ end
135
+
136
+ describe ".render — byte-stable across calls (Story 8.0a)" do
137
+ it "produces byte-identical output for identical snapshots" do
138
+ first = described_class.render(base_snapshot)
139
+ second = described_class.render(base_snapshot.dup)
140
+ expect(first).to eq(second)
141
+ end
142
+ end
143
+
144
+ describe ".render — accepts symbol-keyed and string-keyed function entries" do
145
+ it "tolerates either key style" do
146
+ string_keys = base_snapshot.merge(functions: [
147
+ { "ruby_symbol" => "demo_ping",
148
+ "js_identifier" => "demoPing",
149
+ "kind" => "action" }
150
+ ])
151
+ symbol_keys = base_snapshot.merge(functions: [
152
+ { ruby_symbol: "demo_ping",
153
+ js_identifier: "demoPing",
154
+ kind: "action" }
155
+ ])
156
+ expect(described_class.render(string_keys)).to eq(described_class.render(symbol_keys))
157
+ end
158
+ end
159
+
160
+ describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-13)" do
161
+ it "rejects a snapshot entry whose js_identifier is not a valid JS identifier " \
162
+ "(would otherwise inject TS at module top level)" do
163
+ snapshot = base_snapshot.merge(functions: [
164
+ { "ruby_symbol" => "create_post",
165
+ "js_identifier" => ");\nevil();_makeRef(\"x",
166
+ "kind" => "action" }
167
+ ])
168
+ expect { described_class.render(snapshot) }
169
+ .to raise_error(Ruact::ConfigurationError) do |error|
170
+ expect(error.message).to include("snapshot")
171
+ expect(error.message).to include("valid JS identifier")
172
+ end
173
+ end
174
+
175
+ it "rejects an entry with a non-String js_identifier" do
176
+ snapshot = base_snapshot.merge(functions: [
177
+ { "ruby_symbol" => "create_post",
178
+ "js_identifier" => nil,
179
+ "kind" => "action" }
180
+ ])
181
+ expect { described_class.render(snapshot) }
182
+ .to raise_error(Ruact::ConfigurationError, /valid JS identifier/)
183
+ end
184
+
185
+ it "JSON-escapes the ruby_symbol argument to _makeRef so backslashes / quotes " \
186
+ "in a (hand-edited or corrupted) snapshot cannot break out of the string literal" do
187
+ # The js_identifier still has to be a valid identifier — only the
188
+ # ruby_symbol can carry arbitrary string content. Escape it.
189
+ snapshot = base_snapshot.merge(functions: [
190
+ { "ruby_symbol" => "weird\"\\name",
191
+ "js_identifier" => "weirdName",
192
+ "kind" => "action" }
193
+ ])
194
+ out = described_class.render(snapshot)
195
+ expect(out).to include('_makeRef("weird\"\\\\name");')
196
+ end
197
+ end
198
+
199
+ describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-14)" do
200
+ # The Ruby renderer reads the same on-disk JSON bridge as the JS-side
201
+ # renderer (rake task path + Railtie path), so the same guards must
202
+ # apply on both sides. Mirrors the JS-side `validateSnapshot`.
203
+
204
+ it "rejects a snapshot whose version contains a line break" do
205
+ evil = base_snapshot.merge(version: "1\n// injected")
206
+ expect { described_class.render(evil) }
207
+ .to raise_error(Ruact::ConfigurationError, /version.*line break/)
208
+ end
209
+
210
+ it "rejects a snapshot whose generated_at contains a line break" do
211
+ evil = base_snapshot.merge(generated_at: "2026-05-14\n// injected")
212
+ expect { described_class.render(evil) }
213
+ .to raise_error(Ruact::ConfigurationError, /generated_at.*line break/)
214
+ end
215
+
216
+ it "rejects a snapshot whose functions field is not an Array" do
217
+ evil = base_snapshot.merge(functions: "oops")
218
+ expect { described_class.render(evil) }
219
+ .to raise_error(Ruact::ConfigurationError, /functions must be an Array/)
220
+ end
221
+
222
+ it "rejects a snapshot entry whose kind is not in the allowlist" do
223
+ evil = base_snapshot.merge(functions: [
224
+ { "ruby_symbol" => "foo",
225
+ "js_identifier" => "foo",
226
+ "kind" => "mutation" }
227
+ ])
228
+ expect { described_class.render(evil) }
229
+ .to raise_error(Ruact::ConfigurationError, /invalid kind/)
230
+ end
231
+
232
+ it "rejects a snapshot whose entries duplicate a js_identifier" do
233
+ evil = base_snapshot.merge(functions: [
234
+ { "ruby_symbol" => "foo",
235
+ "js_identifier" => "foo",
236
+ "kind" => "action" },
237
+ { "ruby_symbol" => "bar",
238
+ "js_identifier" => "foo",
239
+ "kind" => "query" }
240
+ ])
241
+ expect { described_class.render(evil) }
242
+ .to raise_error(Ruact::ConfigurationError, /duplicate js_identifier "foo"/)
243
+ end
244
+
245
+ it "rejects a snapshot whose js_identifier is a JS reserved word" do
246
+ evil = base_snapshot.merge(functions: [
247
+ { "ruby_symbol" => "delete",
248
+ "js_identifier" => "delete",
249
+ "kind" => "action" }
250
+ ])
251
+ expect { described_class.render(evil) }
252
+ .to raise_error(Ruact::ConfigurationError, /reserved JS word/)
253
+ end
254
+
255
+ it "rejects a snapshot whose version contains a U+2028 line separator " \
256
+ "(Pass-2 patch 2026-05-14 — JS LineTerminator parity)" do
257
+ evil = base_snapshot.merge(version: "1
// injected")
258
+ expect { described_class.render(evil) }
259
+ .to raise_error(Ruact::ConfigurationError, /line break.*U\+2028/)
260
+ end
261
+
262
+ it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator " \
263
+ "(Pass-2 patch 2026-05-14 — JS LineTerminator parity)" do
264
+ evil = base_snapshot.merge(generated_at: "2026-05-14
// injected")
265
+ expect { described_class.render(evil) }
266
+ .to raise_error(Ruact::ConfigurationError, /line break.*U\+2029/)
267
+ end
268
+
269
+ it "wraps Hash#fetch KeyError as Ruact::ConfigurationError when a root key is " \
270
+ "missing (Pass-2 patch 2026-05-14)" do
271
+ evil = { generated_at: "2026-05-14T00:00:00Z", functions: [] } # no :version
272
+ expect { described_class.render(evil) }
273
+ .to raise_error(Ruact::ConfigurationError, /missing required key/)
274
+ end
275
+
276
+ it "rejects an entry with empty ruby_symbol so we never emit _makeRef(\"\") " \
277
+ "(Pass-2 patch 2026-05-14)" do
278
+ evil = base_snapshot.merge(functions: [
279
+ { "ruby_symbol" => "",
280
+ "js_identifier" => "foo",
281
+ "kind" => "action" }
282
+ ])
283
+ expect { described_class.render(evil) }
284
+ .to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
285
+ end
286
+
287
+ it "rejects an entry with nil ruby_symbol (Pass-2 patch 2026-05-14)" do
288
+ evil = base_snapshot.merge(functions: [
289
+ { "ruby_symbol" => nil,
290
+ "js_identifier" => "foo",
291
+ "kind" => "action" }
292
+ ])
293
+ expect { described_class.render(evil) }
294
+ .to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
295
+ end
296
+
297
+ it "rejects when snapshot is not a Hash" do
298
+ expect { described_class.render("oops") }
299
+ .to raise_error(Ruact::ConfigurationError, /snapshot must be a Hash/)
300
+ end
301
+ end
302
+
303
+ describe ".generate_ts! (Story 8.0a — write-if-changed wrapper)" do
304
+ around do |example|
305
+ Dir.mktmpdir do |dir|
306
+ @tmpdir = dir
307
+ example.run
308
+ end
309
+ end
310
+
311
+ let(:path) { File.join(@tmpdir, "server-functions.ts") }
312
+
313
+ it "writes the file on first call and returns true" do
314
+ expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
315
+ .to be(true)
316
+ expect(File.read(path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
317
+ end
318
+
319
+ it "does NOT rewrite when the content is byte-identical (Story 8.0a)" do
320
+ described_class.generate_ts!(snapshot: base_snapshot, output_path: path)
321
+ expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
322
+ .to be(false)
323
+ end
324
+ end
325
+
326
+ describe ".render — route-driven v2 snapshot (Story 9.3)", :story_9_3 do
327
+ let(:v2_snapshot) do
328
+ {
329
+ version: 2,
330
+ generated_at: "2026-06-09T00:00:00Z",
331
+ functions: [
332
+ { "js_identifier" => "createPost", "kind" => "action",
333
+ "http_method" => "POST", "path" => "/posts", "segments" => [],
334
+ "controller" => "posts", "action" => "create" },
335
+ { "js_identifier" => "updatePost", "kind" => "action",
336
+ "http_method" => "PATCH", "path" => "/posts/:id", "segments" => ["id"],
337
+ "controller" => "posts", "action" => "update" }
338
+ ]
339
+ }
340
+ end
341
+
342
+ it "dispatches on version 2 and emits _makeServerFunction with real path+verb" do
343
+ out = described_class.render(v2_snapshot)
344
+ expect(out).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
345
+ expect(out).to include('_makeServerFunction({ method: "POST", path: "/posts", segments: [] });')
346
+ expect(out).to include('_makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });')
347
+ expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
348
+ end
349
+
350
+ it "keeps the Story 8.2 intersection signature on v2 actions (form action support)" do
351
+ out = described_class.render(v2_snapshot)
352
+ expect(out).to include("& ((formData: FormData) => Promise<void>)")
353
+ end
354
+
355
+ it "emits the empty-v2 module when no routes are exposed" do
356
+ out = described_class.render(version: 2, generated_at: "2026-06-09T00:00:00Z", functions: [])
357
+ expect(out).to include("void _makeServerFunction;")
358
+ expect(out).to include("(no server functions exposed yet")
359
+ expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
360
+ end
361
+
362
+ it "rejects a v2 entry with an invalid http_method" do
363
+ evil = v2_snapshot.merge(functions: [
364
+ { "js_identifier" => "createPost", "kind" => "action",
365
+ "http_method" => "GET", "path" => "/posts", "segments" => [] }
366
+ ])
367
+ expect { described_class.render(evil) }
368
+ .to raise_error(Ruact::ConfigurationError, /invalid http_method/)
369
+ end
370
+
371
+ it "rejects a v2 entry declaring a segment absent from the path" do
372
+ evil = v2_snapshot.merge(functions: [
373
+ { "js_identifier" => "updatePost", "kind" => "action",
374
+ "http_method" => "PATCH", "path" => "/posts", "segments" => ["id"] }
375
+ ])
376
+ expect { described_class.render(evil) }
377
+ .to raise_error(Ruact::ConfigurationError, /absent from path/)
378
+ end
379
+
380
+ it "rejects a v2 entry with an unknown kind (Story 9.5 — action/query only)" do
381
+ evil = v2_snapshot.merge(functions: [
382
+ { "js_identifier" => "createPost", "kind" => "mutation",
383
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
384
+ ])
385
+ expect { described_class.render(evil) }
386
+ .to raise_error(Ruact::ConfigurationError, /v2 entries are "action" or "query"/)
387
+ end
388
+
389
+ it "rejects a v2 entry whose js_identifier is reserved" do
390
+ evil = v2_snapshot.merge(functions: [
391
+ { "js_identifier" => "revalidate", "kind" => "action",
392
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
393
+ ])
394
+ expect { described_class.render(evil) }
395
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
396
+ end
397
+
398
+ it "rejects a v2 entry named after the v2 runtime accessor (_makeServerFunction)" do
399
+ evil = v2_snapshot.merge(functions: [
400
+ { "js_identifier" => "_makeServerFunction", "kind" => "action",
401
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
402
+ ])
403
+ expect { described_class.render(evil) }
404
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
405
+ end
406
+
407
+ it "rejects a v2 path with an undeclared dynamic segment (bidirectional guard)" do
408
+ evil = v2_snapshot.merge(functions: [
409
+ { "js_identifier" => "updatePost", "kind" => "action",
410
+ "http_method" => "PATCH", "path" => "/posts/:id",
411
+ "segments" => [] }
412
+ ])
413
+ expect { described_class.render(evil) }
414
+ .to raise_error(Ruact::ConfigurationError, /not declared in segments/)
415
+ end
416
+
417
+ it "rejects a v2 segment that only substring-matches a longer path token" do
418
+ evil = v2_snapshot.merge(functions: [
419
+ { "js_identifier" => "updatePost", "kind" => "action",
420
+ "http_method" => "PATCH", "path" => "/posts/:id_extra",
421
+ "segments" => ["id"] }
422
+ ])
423
+ expect { described_class.render(evil) }
424
+ .to raise_error(Ruact::ConfigurationError, /absent from path/)
425
+ end
426
+ end
427
+
428
+ describe ".render — v2 query entries (Story 9.5)", :story_9_5 do
429
+ def query_entry(js_id, path, accepts_params:)
430
+ {
431
+ "js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
432
+ "path" => path, "segments" => [], "accepts_params" => accepts_params,
433
+ "controller" => "CatalogQuery", "action" => js_id
434
+ }
435
+ end
436
+
437
+ it "emits a no-param query as _makeQuery with the () signature + useQuery re-export" do
438
+ out = described_class.render(version: 2, generated_at: "t", functions: [
439
+ query_entry("categories", "/q/categories", accepts_params: false)
440
+ ])
441
+ expect(out).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
442
+ expect(out).to include("export const categories: () => Promise<unknown> =")
443
+ expect(out).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
444
+ expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
445
+ expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
446
+ end
447
+
448
+ it "emits a param-declaring query with the (params) signature" do
449
+ out = described_class.render(version: 2, generated_at: "t", functions: [
450
+ query_entry("searchUsers", "/q/searchUsers", accepts_params: true)
451
+ ])
452
+ expect(out).to include("export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =")
453
+ expect(out).to include('_makeQuery({ path: "/q/searchUsers", kind: "query" });')
454
+ end
455
+
456
+ it "imports both accessors and re-exports useQuery for a mixed action+query snapshot" do
457
+ out = described_class.render(version: 2, generated_at: "t", functions: [
458
+ { "js_identifier" => "createPost", "kind" => "action",
459
+ "http_method" => "POST", "path" => "/posts", "segments" => [] },
460
+ query_entry("categories", "/q/categories", accepts_params: false)
461
+ ])
462
+ expect(out).to include('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";')
463
+ expect(out).to include("_makeServerFunction({ method: \"POST\"")
464
+ expect(out).to include("_makeQuery({ path: \"/q/categories\", kind: \"query\" })")
465
+ expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
466
+ end
467
+
468
+ it "accepts GET for a query entry (queries are GET-only)" do
469
+ expect do
470
+ described_class.render(version: 2, generated_at: "t", functions: [
471
+ query_entry("categories", "/q/categories", accepts_params: false)
472
+ ])
473
+ end.not_to raise_error
474
+ end
475
+
476
+ it "rejects a non-GET verb on a query entry" do
477
+ evil = { "js_identifier" => "categories", "kind" => "query", "http_method" => "POST",
478
+ "path" => "/q/categories", "segments" => [] }
479
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
480
+ .to raise_error(Ruact::ConfigurationError, /invalid http_method/)
481
+ end
482
+
483
+ it "rejects a query entry whose js_identifier is the useQuery re-export name" do
484
+ evil = { "js_identifier" => "useQuery", "kind" => "query", "http_method" => "GET",
485
+ "path" => "/q/useQuery", "segments" => [], "accepts_params" => false }
486
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
487
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
488
+ end
489
+
490
+ it "rejects a query entry whose js_identifier is the _makeQuery import name" do
491
+ evil = { "js_identifier" => "_makeQuery", "kind" => "query", "http_method" => "GET",
492
+ "path" => "/q/_makeQuery", "segments" => [], "accepts_params" => false }
493
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
494
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
495
+ end
496
+
497
+ it "does NOT re-export useQuery for an action-only snapshot (byte-stable with 9.3)" do
498
+ out = described_class.render(version: 2, generated_at: "t", functions: [
499
+ { "js_identifier" => "createPost", "kind" => "action",
500
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
501
+ ])
502
+ expect(out).not_to include("useQuery")
503
+ expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
504
+ end
505
+ end
506
+ end
507
+ end
508
+ end