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.
- 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 +3 -2
- 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 +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact/server_functions/error_payload"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
RSpec.describe ErrorPayload, :story_8_4 do
|
|
9
|
+
let(:gem_root) { "/tmp/ruact-test-gem" }
|
|
10
|
+
|
|
11
|
+
before { allow(Ruact).to receive(:gem_path).and_return(gem_root) }
|
|
12
|
+
|
|
13
|
+
# Build an exception whose `.class.name` is spoofed so the spec can
|
|
14
|
+
# exercise the RecordInvalid path without requiring ActiveRecord.
|
|
15
|
+
def build_record_invalid(full_messages: ["Title can't be blank"])
|
|
16
|
+
record = Object.new
|
|
17
|
+
errors = Object.new
|
|
18
|
+
record.define_singleton_method(:errors) { errors }
|
|
19
|
+
errors.define_singleton_method(:full_messages) { full_messages }
|
|
20
|
+
|
|
21
|
+
klass = Class.new(StandardError) do
|
|
22
|
+
attr_reader :record
|
|
23
|
+
define_singleton_method(:name) { "ActiveRecord::RecordInvalid" }
|
|
24
|
+
def initialize(record, message)
|
|
25
|
+
super(message)
|
|
26
|
+
@record = record
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
err = klass.new(record, "Validation failed: Title can't be blank")
|
|
30
|
+
err.set_backtrace([
|
|
31
|
+
"/Users/dev/host/app/controllers/posts_controller.rb:12:in `create'",
|
|
32
|
+
"#{gem_root}/lib/ruact/server_functions/endpoint_controller.rb:111:in `dispatch'"
|
|
33
|
+
])
|
|
34
|
+
err
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe ".build in :development mode" do
|
|
38
|
+
let(:error) { build_record_invalid }
|
|
39
|
+
|
|
40
|
+
subject(:payload) do
|
|
41
|
+
described_class.build(action_name: :create_post, error: error, mode: :development)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "carries the discriminator" do
|
|
45
|
+
expect(payload["_ruact_server_action_error"]).to be(true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "exposes the action name as a string (symbol input is coerced)" do
|
|
49
|
+
expect(payload["action_name"]).to eq("create_post")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "exposes the error class as a string" do
|
|
53
|
+
expect(payload["error_class"]).to eq("ActiveRecord::RecordInvalid")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "exposes the error message" do
|
|
57
|
+
expect(payload["message"]).to eq("Validation failed: Title can't be blank")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "splits the backtrace into app_frames and gem_frames" do
|
|
61
|
+
expect(payload["app_frames"]).to contain_exactly(
|
|
62
|
+
a_string_including("posts_controller.rb:12")
|
|
63
|
+
)
|
|
64
|
+
expect(payload["gem_frames"]).to contain_exactly(
|
|
65
|
+
a_string_including("endpoint_controller.rb:111")
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "extracts validation_errors from the record" do
|
|
70
|
+
expect(payload["validation_errors"]).to eq(["Title can't be blank"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "extracts the contextual suggestion" do
|
|
74
|
+
expect(payload["suggestion"]).to eq("Validation failed — check the model's `validates` rules")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe ".build in :production mode" do
|
|
79
|
+
let(:error) { build_record_invalid }
|
|
80
|
+
|
|
81
|
+
subject(:payload) do
|
|
82
|
+
described_class.build(action_name: :create_post, error: error, mode: :production)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "carries the four baseline keys" do
|
|
86
|
+
expect(payload.keys).to contain_exactly(
|
|
87
|
+
"_ruact_server_action_error",
|
|
88
|
+
"action_name",
|
|
89
|
+
"error_class",
|
|
90
|
+
"message"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "shares the four baseline values with the dev-mode payload" do
|
|
95
|
+
dev = described_class.build(action_name: :create_post, error: error, mode: :development)
|
|
96
|
+
payload.each_key do |key|
|
|
97
|
+
expect(payload[key]).to eq(dev[key])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "ABSENT (not null): app_frames, gem_frames, suggestion, validation_errors" do
|
|
102
|
+
expect(payload).not_to have_key("app_frames")
|
|
103
|
+
expect(payload).not_to have_key("gem_frames")
|
|
104
|
+
expect(payload).not_to have_key("suggestion")
|
|
105
|
+
expect(payload).not_to have_key("validation_errors")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "validation_errors edge cases (dev mode)" do
|
|
110
|
+
it "is [] when RecordInvalid was constructed without a record" do
|
|
111
|
+
klass = Class.new(StandardError) do
|
|
112
|
+
attr_reader :record
|
|
113
|
+
define_singleton_method(:name) { "ActiveRecord::RecordInvalid" }
|
|
114
|
+
def initialize
|
|
115
|
+
super("boom")
|
|
116
|
+
@record = nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
payload = described_class.build(action_name: :x, error: klass.new, mode: :development)
|
|
120
|
+
expect(payload["validation_errors"]).to eq([])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "is ABSENT (no key) when the error class is not RecordInvalid" do
|
|
124
|
+
payload = described_class.build(
|
|
125
|
+
action_name: :x,
|
|
126
|
+
error: RuntimeError.new("boom"),
|
|
127
|
+
mode: :development
|
|
128
|
+
)
|
|
129
|
+
expect(payload).not_to have_key("validation_errors")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe "suggestion is null for unknown error classes (dev mode)" do
|
|
134
|
+
it "produces suggestion: nil for RuntimeError" do
|
|
135
|
+
payload = described_class.build(
|
|
136
|
+
action_name: :x,
|
|
137
|
+
error: RuntimeError.new("boom"),
|
|
138
|
+
mode: :development
|
|
139
|
+
)
|
|
140
|
+
expect(payload["suggestion"]).to be_nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
describe "Pitfall #5 — frozen-string error message safety" do
|
|
145
|
+
it "does not raise FrozenError when the error message is frozen" do
|
|
146
|
+
err = StandardError.new("frozen msg".freeze)
|
|
147
|
+
expect do
|
|
148
|
+
described_class.build(action_name: :x, error: err, mode: :development)
|
|
149
|
+
end.not_to raise_error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "stores a mutable dup of the message in the payload" do
|
|
153
|
+
err = StandardError.new("frozen msg".freeze)
|
|
154
|
+
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
155
|
+
expect(payload["message"]).to eq("frozen msg")
|
|
156
|
+
expect(payload["message"]).not_to be_frozen
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe "Story 8.5 — UploadTooLargeError gets a dev-only upload_limit block", :story_8_5 do
|
|
161
|
+
let(:error) { Ruact::UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760) }
|
|
162
|
+
|
|
163
|
+
it "exposes received_bytes and limit_bytes under upload_limit (dev mode)" do
|
|
164
|
+
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
165
|
+
expect(payload["upload_limit"]).to eq(
|
|
166
|
+
"received_bytes" => 11_534_336,
|
|
167
|
+
"limit_bytes" => 10_485_760
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "carries error_class as Ruact::UploadTooLargeError" do
|
|
172
|
+
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
173
|
+
expect(payload["error_class"]).to eq("Ruact::UploadTooLargeError")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "extracts the upload-too-large suggestion from ErrorSuggestion" do
|
|
177
|
+
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
178
|
+
expect(payload["suggestion"]).to include("Increase Ruact.config.max_upload_bytes")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "does NOT include upload_limit in :production mode (four baseline keys only)" do
|
|
182
|
+
payload = described_class.build(action_name: :upload_post, error: error, mode: :production)
|
|
183
|
+
expect(payload).not_to have_key("upload_limit")
|
|
184
|
+
expect(payload.keys).to contain_exactly(
|
|
185
|
+
"_ruact_server_action_error",
|
|
186
|
+
"action_name",
|
|
187
|
+
"error_class",
|
|
188
|
+
"message"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "is ABSENT (no key) when the error class is not UploadTooLargeError" do
|
|
193
|
+
payload = described_class.build(
|
|
194
|
+
action_name: :x,
|
|
195
|
+
error: RuntimeError.new("boom"),
|
|
196
|
+
mode: :development
|
|
197
|
+
)
|
|
198
|
+
expect(payload).not_to have_key("upload_limit")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe "backtrace edge cases (dev mode)" do
|
|
203
|
+
it "is { app_frames: [], gem_frames: [] } when backtrace is nil" do
|
|
204
|
+
err = StandardError.new("boom") # never raised => backtrace is nil
|
|
205
|
+
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
206
|
+
expect(payload["app_frames"]).to eq([])
|
|
207
|
+
expect(payload["gem_frames"]).to eq([])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it "caps each bucket at MAX_FRAMES_PER_BUCKET frames" do
|
|
211
|
+
app_frames = Array.new(40) { |i| "/Users/dev/host/app/file_#{i}.rb:#{i}" }
|
|
212
|
+
gem_frames = Array.new(40) { |i| "#{gem_root}/lib/ruact/file_#{i}.rb:#{i}" }
|
|
213
|
+
err = StandardError.new("boom")
|
|
214
|
+
err.set_backtrace(app_frames + gem_frames)
|
|
215
|
+
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
216
|
+
expect(payload["app_frames"].size).to eq(ErrorPayload::MAX_FRAMES_PER_BUCKET)
|
|
217
|
+
expect(payload["gem_frames"].size).to eq(ErrorPayload::MAX_FRAMES_PER_BUCKET)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact/server_functions/error_suggestion"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
RSpec.describe ErrorSuggestion, :story_8_4 do
|
|
9
|
+
# Test fixtures using class-name spoofing so the module can be exercised
|
|
10
|
+
# without requiring ActiveRecord or ActionController to be loaded
|
|
11
|
+
# (Pitfall #4).
|
|
12
|
+
def error_with_class_name(name)
|
|
13
|
+
klass = Class.new(StandardError) do
|
|
14
|
+
define_singleton_method(:name) { name }
|
|
15
|
+
end
|
|
16
|
+
klass.new("fixture")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe "Story 8.4 — .for returns the NFR30-mandated suggestion" do
|
|
20
|
+
it "returns the RecordInvalid suggestion for ActiveRecord::RecordInvalid" do
|
|
21
|
+
err = error_with_class_name("ActiveRecord::RecordInvalid")
|
|
22
|
+
expect(described_class.for(err))
|
|
23
|
+
.to eq("Validation failed — check the model's `validates` rules")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "returns the CSRF suggestion for ActionController::InvalidAuthenticityToken" do
|
|
27
|
+
err = error_with_class_name("ActionController::InvalidAuthenticityToken")
|
|
28
|
+
expect(described_class.for(err))
|
|
29
|
+
.to eq(
|
|
30
|
+
"CSRF token mismatch — ensure the page was rendered after the most recent server restart and the session cookie is intact"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "returns nil for StandardError" do
|
|
35
|
+
expect(described_class.for(StandardError.new("boom"))).to be_nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "returns nil for RuntimeError" do
|
|
39
|
+
expect(described_class.for(RuntimeError.new("boom"))).to be_nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "returns nil for ArgumentError" do
|
|
43
|
+
expect(described_class.for(ArgumentError.new("boom"))).to be_nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "returns nil for a custom user exception class" do
|
|
47
|
+
custom_class = Class.new(StandardError) { define_singleton_method(:name) { "MyApp::PaymentDeclined" } }
|
|
48
|
+
expect(described_class.for(custom_class.new("declined"))).to be_nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe "Story 8.5 — UploadTooLargeError suggestion", :story_8_5 do
|
|
53
|
+
it "returns the upload-too-large suggestion for Ruact::UploadTooLargeError" do
|
|
54
|
+
err = Ruact::UploadTooLargeError.new(received_bytes: 11_000_000, limit_bytes: 10_485_760)
|
|
55
|
+
expect(described_class.for(err))
|
|
56
|
+
.to eq(
|
|
57
|
+
"Upload exceeded the configured size limit. " \
|
|
58
|
+
"Increase Ruact.config.max_upload_bytes or use Active Storage Direct Upload / " \
|
|
59
|
+
"a presigned S3 URL for large files."
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "has a SUGGESTIONS entry keyed by the exception class name" do
|
|
64
|
+
expect(described_class::SUGGESTIONS).to have_key("Ruact::UploadTooLargeError")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "Story 8.4 — SUGGESTIONS constant" do
|
|
69
|
+
it "is frozen so runtime mutation cannot extend the table" do
|
|
70
|
+
expect(described_class::SUGGESTIONS).to be_frozen
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "uses class-name strings as keys (not constants)" do
|
|
74
|
+
expect(described_class::SUGGESTIONS.keys).to all(be_a(String))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module ServerFunctions
|
|
7
|
+
RSpec.describe NameBridge, :story_8_0a do
|
|
8
|
+
describe ".to_js_identifier (Story 8.0a — Ruby symbol → JS identifier)" do
|
|
9
|
+
# Six canonical edge cases locked by the Story 8.0 ADR.
|
|
10
|
+
|
|
11
|
+
it "translates standard snake_case to camelCase" do
|
|
12
|
+
expect(described_class.to_js_identifier(:create_post)).to eq("createPost")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "passes a single word through unchanged" do
|
|
16
|
+
expect(described_class.to_js_identifier(:categories)).to eq("categories")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "preserves a single leading underscore" do
|
|
20
|
+
expect(described_class.to_js_identifier(:_internal_dump)).to eq("_internalDump")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "collapses consecutive underscores" do
|
|
24
|
+
expect(described_class.to_js_identifier(:foo__bar)).to eq("fooBar")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "raises ConfigurationError for SCREAMING_SNAKE" do
|
|
28
|
+
expect { described_class.to_js_identifier(:RECALCULATE) }
|
|
29
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
30
|
+
expect(error.message).to include(":RECALCULATE")
|
|
31
|
+
expect(error.message).to include("/^[a-z_][a-z0-9_]*$/")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "raises ConfigurationError for PascalCase" do
|
|
36
|
+
expect { described_class.to_js_identifier(:CreatePost) }
|
|
37
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
38
|
+
expect(error.message).to include(":CreatePost")
|
|
39
|
+
expect(error.message).to include("ruact_action / ruact_query")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe "additional shape guards" do
|
|
44
|
+
it "rejects symbols starting with a digit" do
|
|
45
|
+
expect { described_class.to_js_identifier(:"1foo") }
|
|
46
|
+
.to raise_error(Ruact::ConfigurationError, /must match/)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "rejects symbols containing a dash" do
|
|
50
|
+
expect { described_class.to_js_identifier(:"foo-bar") }
|
|
51
|
+
.to raise_error(Ruact::ConfigurationError, /must match/)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "rejects empty string" do
|
|
55
|
+
expect { described_class.to_js_identifier(:"") }
|
|
56
|
+
.to raise_error(Ruact::ConfigurationError, /must match/)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "accepts a string argument identical to its symbol form" do
|
|
60
|
+
expect(described_class.to_js_identifier("create_post")).to eq("createPost")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "preserves a leading underscore followed by digits" do
|
|
64
|
+
expect(described_class.to_js_identifier(:_2fa_check)).to eq("_2faCheck")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "underscore-only symbols (Story 8.0 review patch 2026-05-13)" do
|
|
69
|
+
it "rejects a single underscore" do
|
|
70
|
+
expect { described_class.to_js_identifier(:_) }
|
|
71
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
72
|
+
expect(error.message).to include(":_")
|
|
73
|
+
expect(error.message).to include("entirely of underscores")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects a run of underscores" do
|
|
78
|
+
expect { described_class.to_js_identifier(:____) }
|
|
79
|
+
.to raise_error(Ruact::ConfigurationError, /entirely of underscores/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "still accepts a leading underscore followed by alphanumeric" do
|
|
83
|
+
expect(described_class.to_js_identifier(:_x)).to eq("_x")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe "JS reserved words (Story 8.0 review patch 2026-05-13)" do
|
|
88
|
+
# Spot-check a handful of representative classes: keyword (`class`),
|
|
89
|
+
# module-level reserved (`export`), strict-mode reserved (`let`),
|
|
90
|
+
# contextually-reserved (`await`, `async`), literal (`true`).
|
|
91
|
+
it "rejects :class" do
|
|
92
|
+
expect { described_class.to_js_identifier(:class) }
|
|
93
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
94
|
+
expect(error.message).to include(":class")
|
|
95
|
+
expect(error.message).to include("JS reserved word")
|
|
96
|
+
expect(error.message).to include('"class"')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "rejects :export" do
|
|
101
|
+
expect { described_class.to_js_identifier(:export) }
|
|
102
|
+
.to raise_error(Ruact::ConfigurationError, /JS reserved word.*"export"/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "rejects :let, :await, :async, :true (representative coverage)" do
|
|
106
|
+
%i[let await async true].each do |sym|
|
|
107
|
+
expect { described_class.to_js_identifier(sym) }
|
|
108
|
+
.to raise_error(Ruact::ConfigurationError, /JS reserved word/),
|
|
109
|
+
"expected :#{sym} to raise as a reserved word"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "rejects :eval and :arguments (strict-mode invalid binding names — " \
|
|
114
|
+
"Story 8.0 Re-run patch 2026-05-13)" do
|
|
115
|
+
# ES module code runs in strict mode, where `eval` and `arguments`
|
|
116
|
+
# cannot be used as identifier names. The 8.0a codegen emits a
|
|
117
|
+
# `"type": "module"` file, so these guards apply unconditionally.
|
|
118
|
+
%i[eval arguments].each do |sym|
|
|
119
|
+
expect { described_class.to_js_identifier(sym) }
|
|
120
|
+
.to raise_error(Ruact::ConfigurationError, /JS reserved word/),
|
|
121
|
+
"expected :#{sym} to raise as strict-mode invalid binding name"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "accepts multi-word symbols whose camelCased output is not reserved" do
|
|
126
|
+
# No real Ruby snake_case maps to a single reserved word post-
|
|
127
|
+
# camelCasing (reserved words are themselves single-word), but the
|
|
128
|
+
# check happens AFTER camelCasing so a hypothetical degenerate
|
|
129
|
+
# input is still caught.
|
|
130
|
+
expect { described_class.to_js_identifier(:cl_ass) }
|
|
131
|
+
.not_to raise_error # "clAss" is not reserved — sanity guard
|
|
132
|
+
expect(described_class.to_js_identifier(:cl_ass)).to eq("clAss")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "ACCEPTS reserved words that survive only with the leading-underscore prefix" do
|
|
136
|
+
# `:_class` → "_class" — not reserved (the underscore is a literal
|
|
137
|
+
# character); intentional escape hatch for devs whose domain
|
|
138
|
+
# vocabulary collides with JS keywords.
|
|
139
|
+
expect(described_class.to_js_identifier(:_class)).to eq("_class")
|
|
140
|
+
expect(described_class.to_js_identifier(:_export)).to eq("_export")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "ACCEPTS the suffix-shaped escape hint from the error message" do
|
|
144
|
+
# The error message suggests `:class_action`; assert that hint
|
|
145
|
+
# produces a valid identifier — guards against a regression where
|
|
146
|
+
# the suggested fix would also fail validation.
|
|
147
|
+
expect(described_class.to_js_identifier(:class_action)).to eq("classAction")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "Ruact-reserved names (Story 8.2 review patch R2 — 2026-05-17)", :story_8_2 do
|
|
152
|
+
# The codegen unconditionally re-exports certain runtime helpers
|
|
153
|
+
# from `@/.ruact/server-functions` (e.g. `revalidate`). A
|
|
154
|
+
# `ruact_action :revalidate` would emit `export const revalidate`
|
|
155
|
+
# next to the helper re-export and crash at module load with a
|
|
156
|
+
# duplicate-export error. Reject at controller load instead.
|
|
157
|
+
it "rejects :revalidate because it collides with the unconditional helper re-export" do
|
|
158
|
+
expect { described_class.to_js_identifier(:revalidate) }
|
|
159
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
160
|
+
expect(error.message).to include(":revalidate")
|
|
161
|
+
expect(error.message).to include("duplicate export")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "accepts :revalidate_post (suffix escape hatch — same convention as JS reserved-word path)" do
|
|
166
|
+
expect(described_class.to_js_identifier(:revalidate_post)).to eq("revalidatePost")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "accepts :_revalidate (leading-underscore escape hatch)" do
|
|
170
|
+
expect(described_class.to_js_identifier(:_revalidate)).to eq("_revalidate")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "R12 — rejects :_make_ref because it collides with the codegen's runtime import" do
|
|
174
|
+
expect { described_class.to_js_identifier(:_make_ref) }
|
|
175
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
176
|
+
expect(error.message).to include(":_make_ref")
|
|
177
|
+
expect(error.message).to include("duplicate export")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "R12 — accepts :_make_ref_action (suffix escape hatch keeps working)" do
|
|
182
|
+
expect(described_class.to_js_identifier(:_make_ref_action)).to eq("_makeRefAction")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 9.4 — unit spec for the per-request query context (D3). The context
|
|
4
|
+
# wraps the DISPATCHING controller instance and delegates to it: because the
|
|
5
|
+
# internal dispatch controller inherits `Ruact.config.query_parent_controller`,
|
|
6
|
+
# `controller.current_user` IS the host's own method (Devise / Pundit /
|
|
7
|
+
# hand-rolled). No Rails boot needed here — a plain stub controller suffices.
|
|
8
|
+
require "spec_helper"
|
|
9
|
+
|
|
10
|
+
module Ruact
|
|
11
|
+
module ServerFunctions
|
|
12
|
+
RSpec.describe QueryContext, :story_9_4 do
|
|
13
|
+
describe "Story 9.4 — delegation to the dispatching controller (AC3 / D3)" do
|
|
14
|
+
let(:controller_class) do
|
|
15
|
+
Class.new do
|
|
16
|
+
def params = { "q" => "x" }
|
|
17
|
+
def request = :the_request
|
|
18
|
+
def session = { "sid" => 1 }
|
|
19
|
+
|
|
20
|
+
def current_user
|
|
21
|
+
{ "id" => 7 }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
subject(:context) { described_class.new(controller: controller_class.new) }
|
|
27
|
+
|
|
28
|
+
it "delegates params" do
|
|
29
|
+
expect(context.params).to eq("q" => "x")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "delegates request" do
|
|
33
|
+
expect(context.request).to eq(:the_request)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "delegates session" do
|
|
37
|
+
expect(context.session).to eq("sid" => 1)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "delegates current_user to the host's own method" do
|
|
41
|
+
expect(context.current_user).to eq("id" => 7)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe "Story 9.4 — current_user reaches a PRIVATE host helper too" do
|
|
46
|
+
let(:controller_class) do
|
|
47
|
+
Class.new do
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def current_user
|
|
51
|
+
:private_user
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "resolves it (hand-rolled apps commonly define current_user private)" do
|
|
57
|
+
context = described_class.new(controller: controller_class.new)
|
|
58
|
+
expect(context.current_user).to eq(:private_user)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "Story 9.4 — clear error when the host defines no current_user (D3)" do
|
|
63
|
+
it "raises NoMethodError naming the parent controller and the fix" do
|
|
64
|
+
context = described_class.new(controller: Class.new.new)
|
|
65
|
+
expect { context.current_user }.to raise_error(NoMethodError, /current_user/) do |error|
|
|
66
|
+
expect(error.message).to include("query_parent_controller")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|