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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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
|