ruact 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) 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 +3 -2
  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 +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.2 — unit spec for the pure Bucket-2 response serializer. Pins the
4
+ # prop-exposure policy mirrored from Ruact::Flight::Serializer#serialize_unknown
5
+ # (Serializable → ruact_props only; strict → raise; vetted as_json fallback),
6
+ # producing a plain JSON-ready Hash (no Flight wire encoding). Pure function —
7
+ # no Rails / request / Ruact.config reads (NFR26 / AC8).
8
+
9
+ require "spec_helper"
10
+ require "ruact/server_functions/bucket_two_payload"
11
+
12
+ # Fixtures live OUTSIDE the example group (no leaky constants in the block).
13
+ module B2Fixtures
14
+ # A Serializable model exposing only some attributes.
15
+ class Post
16
+ include Ruact::Serializable
17
+
18
+ attr_reader :id, :title, :secret
19
+
20
+ def initialize(id:, title:, secret:)
21
+ @id = id
22
+ @title = title
23
+ @secret = secret
24
+ end
25
+
26
+ ruact_props :id, :title
27
+ end
28
+
29
+ # Serializable whose prop is itself a Serializable (nested).
30
+ class AuthoredPost
31
+ include Ruact::Serializable
32
+
33
+ attr_reader :title, :author
34
+
35
+ def initialize(title:, author:)
36
+ @title = title
37
+ @author = author
38
+ end
39
+
40
+ ruact_props :title, :author
41
+ end
42
+
43
+ class Author
44
+ include Ruact::Serializable
45
+
46
+ attr_reader :name, :password_digest
47
+
48
+ def initialize(name:, password_digest:)
49
+ @name = name
50
+ @password_digest = password_digest
51
+ end
52
+
53
+ ruact_props :name
54
+ end
55
+
56
+ # A plain object with as_json (AR-like).
57
+ class PlainRecord
58
+ def as_json(_opts = nil)
59
+ { "id" => 7, "leaked" => "everything" }
60
+ end
61
+ end
62
+
63
+ class SelfReturningAsJson
64
+ def as_json(_opts = nil)
65
+ self
66
+ end
67
+ end
68
+
69
+ class RaisingAsJson
70
+ def as_json(_opts = nil)
71
+ raise "boom in as_json"
72
+ end
73
+ end
74
+ end
75
+
76
+ RSpec.describe Ruact::ServerFunctions::BucketTwoPayload, :story_9_2 do
77
+ describe ".build (AC2 — keyed by ivar name, all exposed ivars)" do
78
+ it "keys the result by the assigns names and serializes each value" do
79
+ result = described_class.build(
80
+ { "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "x"), "count" => 3 },
81
+ strict: true
82
+ )
83
+ expect(result).to eq("post" => { "id" => 1, "title" => "Hi" }, "count" => 3)
84
+ end
85
+
86
+ it "does NOT unwrap a single ivar — it stays keyed (no magic unwrap)" do
87
+ result = described_class.build({ "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "x") }, strict: true)
88
+ expect(result).to eq("post" => { "id" => 1, "title" => "Hi" })
89
+ end
90
+ end
91
+
92
+ describe "Serializable policy" do
93
+ it "exposes ONLY ruact_props, never undeclared attributes (no secret leak)" do
94
+ result = described_class.build({ "post" => B2Fixtures::Post.new(id: 1, title: "Hi", secret: "nope") },
95
+ strict: true)
96
+ expect(result.fetch("post")).to eq("id" => 1, "title" => "Hi")
97
+ expect(result.fetch("post")).not_to have_key("secret")
98
+ end
99
+
100
+ it "recurses into a Serializable-valued prop (nested), applying ruact_props at each level" do
101
+ author = B2Fixtures::Author.new(name: "Ada", password_digest: "HASH")
102
+ post = B2Fixtures::AuthoredPost.new(title: "T", author: author)
103
+ result = described_class.build({ "post" => post }, strict: true)
104
+ expect(result.fetch("post")).to eq("title" => "T", "author" => { "name" => "Ada" })
105
+ end
106
+
107
+ it "serializes an Array of Serializables element-wise" do
108
+ posts = [B2Fixtures::Post.new(id: 1, title: "A", secret: "s"),
109
+ B2Fixtures::Post.new(id: 2, title: "B", secret: "s")]
110
+ result = described_class.build({ "posts" => posts }, strict: true)
111
+ expect(result.fetch("posts")).to eq([{ "id" => 1, "title" => "A" }, { "id" => 2, "title" => "B" }])
112
+ end
113
+ end
114
+
115
+ describe "primitive pass-through (NOT subject to strict policy)" do
116
+ it "passes scalars through untouched even under strict" do
117
+ result = described_class.build(
118
+ { "i" => 5, "f" => 1.5, "s" => "x", "t" => true, "n" => nil, "sym" => :ok },
119
+ strict: true
120
+ )
121
+ expect(result).to eq("i" => 5, "f" => 1.5, "s" => "x", "t" => true, "n" => nil, "sym" => :ok)
122
+ end
123
+
124
+ it "passes Time through untouched (Rails render json: handles ISO formatting)" do
125
+ time = Time.utc(2026, 1, 2, 3, 4, 5)
126
+ result = described_class.build({ "at" => time }, strict: true)
127
+ expect(result.fetch("at")).to equal(time)
128
+ end
129
+
130
+ it "stringifies Hash keys and recurses values" do
131
+ assigns = { "meta" => { a: 1, b: B2Fixtures::Post.new(id: 9, title: "N", secret: "s") } }
132
+ result = described_class.build(assigns, strict: true)
133
+ expect(result.fetch("meta")).to eq("a" => 1, "b" => { "id" => 9, "title" => "N" })
134
+ end
135
+ end
136
+
137
+ describe "strict_serialization policy (AC5)" do
138
+ it "raises Ruact::SerializationError for a non-Serializable object under strict" do
139
+ expect { described_class.build({ "rec" => B2Fixtures::PlainRecord.new }, strict: true) }
140
+ .to raise_error(Ruact::SerializationError, /Cannot serialize B2Fixtures::PlainRecord/)
141
+ end
142
+
143
+ it "falls back to as_json when strict is false" do
144
+ result = described_class.build({ "rec" => B2Fixtures::PlainRecord.new }, strict: false)
145
+ expect(result.fetch("rec")).to eq("id" => 7, "leaked" => "everything")
146
+ end
147
+
148
+ it "raises when as_json returns self (infinite-recursion guard), regardless of strict" do
149
+ expect { described_class.build({ "x" => B2Fixtures::SelfReturningAsJson.new }, strict: false) }
150
+ .to raise_error(Ruact::SerializationError, /as_json returned self/)
151
+ end
152
+
153
+ it "wraps an exception raised inside as_json as Ruact::SerializationError" do
154
+ expect { described_class.build({ "x" => B2Fixtures::RaisingAsJson.new }, strict: false) }
155
+ .to raise_error(Ruact::SerializationError, /as_json raised RuntimeError: boom in as_json/)
156
+ end
157
+ end
158
+
159
+ describe "Story 9.4 — .serialize_value (single value, same policy as .build — D6)", :story_9_4 do
160
+ it "serializes a Serializable through ruact_props only" do
161
+ post = B2Fixtures::Post.new(id: 1, title: "Hi", secret: "nope")
162
+ expect(described_class.serialize_value(post, strict: true)).to eq("id" => 1, "title" => "Hi")
163
+ end
164
+
165
+ it "recurses into Arrays of Serializables" do
166
+ posts = [B2Fixtures::Post.new(id: 1, title: "A", secret: "x")]
167
+ expect(described_class.serialize_value(posts, strict: true)).to eq([{ "id" => 1, "title" => "A" }])
168
+ end
169
+
170
+ it "recurses into Hashes, stringifying keys" do
171
+ value = { total: 2, post: B2Fixtures::Post.new(id: 1, title: "A", secret: "x") }
172
+ expect(described_class.serialize_value(value, strict: true))
173
+ .to eq("total" => 2, "post" => { "id" => 1, "title" => "A" })
174
+ end
175
+
176
+ it "passes primitives through untouched" do
177
+ expect(described_class.serialize_value(42, strict: true)).to eq(42)
178
+ end
179
+
180
+ it "passes nil through (the dispatch controller renders it as JSON null — D6)" do
181
+ expect(described_class.serialize_value(nil, strict: true)).to be_nil
182
+ end
183
+
184
+ it "raises Ruact::SerializationError for a non-Serializable under strict" do
185
+ expect { described_class.serialize_value(B2Fixtures::PlainRecord.new, strict: true) }
186
+ .to raise_error(Ruact::SerializationError, /Cannot serialize B2Fixtures::PlainRecord/)
187
+ end
188
+
189
+ it "falls back to as_json when strict is false" do
190
+ expect(described_class.serialize_value(B2Fixtures::PlainRecord.new, strict: false))
191
+ .to eq("id" => 7, "leaked" => "everything")
192
+ end
193
+
194
+ it "is the same policy .build applies per ivar (extraction, not a fork)" do
195
+ post = B2Fixtures::Post.new(id: 3, title: "Same", secret: "x")
196
+ expect(described_class.build({ "post" => post }, strict: true))
197
+ .to eq("post" => described_class.serialize_value(post, strict: true))
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,429 @@
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 a non-action kind" do
381
+ evil = v2_snapshot.merge(functions: [
382
+ { "js_identifier" => "createPost", "kind" => "query",
383
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
384
+ ])
385
+ expect { described_class.render(evil) }
386
+ .to raise_error(Ruact::ConfigurationError, /v2 entries are always/)
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
+ end
428
+ end
429
+ end