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,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