funicular 0.4.0 → 0.5.0

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -63,6 +63,242 @@ class PicorubyHelperTest < Minitest::Test
63
63
  assert_includes html, "defer"
64
64
  end
65
65
 
66
+ # --- page metadata (docs decisions 12/13) -----------------------------
67
+
68
+ def test_disabled_include_tag_omits_all_local_database_metadata
69
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
70
+ refute_includes html, "data-funicular-local-database"
71
+ refute_includes html, "data-funicular-application-id"
72
+ refute_includes html, "data-funicular-user-key"
73
+ refute_includes html, "data-funicular-anonymous-only"
74
+ refute_includes html, "data-funicular-epoch"
75
+ end
76
+
77
+ def test_disabled_include_tag_does_not_call_the_identity_resolver_or_session
78
+ @config.user_key = ->(_controller) { flunk "resolver was called" }
79
+ session = DisabledSession.new
80
+ view = metadata_view(user_key: "u1", session: session)
81
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
82
+ refute_includes html, "data-funicular-local-database"
83
+ end
84
+
85
+ def test_include_tag_embeds_anonymous_only
86
+ @config.local_database = true
87
+ @config.anonymous_only = true
88
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
89
+ assert_includes html, 'data-funicular-local-database="true"'
90
+ assert_includes html, 'data-funicular-application-id="funicular"'
91
+ assert_includes html, 'data-funicular-anonymous-only="true"'
92
+ end
93
+
94
+ def test_enabled_include_tag_requires_an_identity_declaration
95
+ @config.local_database = true
96
+ error = assert_raises(ArgumentError) do
97
+ @view.picoruby_include_tag(source: :local_dist, base_styles: false)
98
+ end
99
+ assert_includes error.message, "user_key"
100
+ end
101
+
102
+ # A harness with the controller/request surface a real view has. The
103
+ # helper reads the session through request.session (the view's own
104
+ # session helper delegates to the controller, where an action named
105
+ # "session" shadows it).
106
+ class MetadataHarness < Harness
107
+ attr_accessor :controller, :request
108
+ end
109
+
110
+ FakeController = Struct.new(:current_user_key)
111
+ FakeViewRequest = Struct.new(:session)
112
+
113
+ def metadata_view(user_key: nil, session: {})
114
+ view = MetadataHarness.new
115
+ view.controller = FakeController.new(user_key)
116
+ view.request = FakeViewRequest.new(session)
117
+ view
118
+ end
119
+
120
+ # A view rendered by a controller that routes an action named
121
+ # "session": the view's session delegate would invoke that action.
122
+ # The helper must go through request.session and never notice.
123
+ class SessionShadowedHarness < MetadataHarness
124
+ def session
125
+ raise "the session delegate was invoked instead of request.session"
126
+ end
127
+ end
128
+
129
+ def test_the_helper_never_calls_the_view_session_delegate
130
+ @config.local_database = true
131
+ @config.user_key = ->(controller) { controller.current_user_key }
132
+ view = SessionShadowedHarness.new
133
+ view.controller = FakeController.new("u1")
134
+ view.request = FakeViewRequest.new({})
135
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
136
+ assert_includes html, "data-funicular-epoch"
137
+ end
138
+
139
+ def test_include_tag_embeds_user_key_and_epoch
140
+ @config.local_database = true
141
+ @config.user_key = ->(controller) { controller.current_user_key }
142
+ session = {}
143
+ view = metadata_view(user_key: "u1", session: session)
144
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
145
+ assert_includes html, 'data-funicular-user-key="u1"'
146
+ assert_includes html, 'data-funicular-user-key-configured="true"'
147
+ # The epoch on the page is THE session entry the response header
148
+ # stamps: page and responses cannot disagree at render time.
149
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
150
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
151
+ end
152
+
153
+ # What request.session looks like in a session-less Rails API app.
154
+ class DisabledSession
155
+ def enabled?
156
+ false
157
+ end
158
+
159
+ def [](_key)
160
+ raise "sessions are disabled in this application"
161
+ end
162
+
163
+ def []=(_key, _value)
164
+ raise "sessions are disabled in this application"
165
+ end
166
+ end
167
+
168
+ def test_a_disabled_session_skips_the_epoch_without_breaking
169
+ @config.local_database = true
170
+ @config.user_key = ->(controller) { controller.current_user_key }
171
+ view = metadata_view(user_key: "u1", session: DisabledSession.new)
172
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
173
+ refute_includes html, "data-funicular-epoch"
174
+ assert_includes html, 'data-funicular-user-key="u1"'
175
+ end
176
+
177
+ def test_the_resolver_feeds_attribute_and_epoch_from_one_evaluation
178
+ # A racy resolver (current_user changing between two calls) must
179
+ # not embed user A's namespace while stamping user B's identity
180
+ # into the epoch entry: the helper evaluates it exactly once.
181
+ @config.local_database = true
182
+ calls = 0
183
+ @config.user_key = lambda do |_controller|
184
+ calls += 1
185
+ "u#{calls}"
186
+ end
187
+ session = {}
188
+ view = metadata_view(user_key: "ignored", session: session)
189
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
190
+ assert_equal 1, calls
191
+ assert_includes html, 'data-funicular-user-key="u1"'
192
+ assert_equal '["v1","funicular","user","u1"]',
193
+ session["funicular_epochs"]["funicular"]["identity"]
194
+ end
195
+
196
+ def test_signed_out_omits_the_user_key_attribute
197
+ @config.local_database = true
198
+ @config.user_key = ->(controller) { controller.current_user_key }
199
+ view = metadata_view(user_key: nil)
200
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
201
+ refute_includes html, "data-funicular-user-key="
202
+ assert_includes html, 'data-funicular-user-key-configured="true"'
203
+ end
204
+
205
+ def test_metadata_values_are_html_escaped
206
+ @config.local_database = true
207
+ @config.application_id = %q{"><script>alert(1)</script>}
208
+ @config.user_key = ->(controller) { controller.current_user_key }
209
+ view = metadata_view(user_key: %q{"><img src=x>})
210
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
211
+ refute_includes html, "<script>alert(1)</script>"
212
+ refute_includes html, "<img src=x>"
213
+ assert_includes html, "&quot;&gt;"
214
+ end
215
+
216
+ def test_caller_data_attributes_survive_alongside_the_metadata
217
+ @config.local_database = true
218
+ @config.anonymous_only = true
219
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false,
220
+ data: { turbo_track: "reload" })
221
+ assert_includes html, 'data-turbo-track="reload"'
222
+ assert_includes html, 'data-funicular-application-id="funicular"'
223
+ end
224
+
225
+ def test_caller_data_cannot_override_the_framework_metadata
226
+ # A data: override of the epoch or the user key would make the
227
+ # page disagree with its own responses (instant terminal) or boot
228
+ # the wrong namespace: the framework's values win, in every key
229
+ # spelling that would render as the same HTML attribute.
230
+ @config.local_database = true
231
+ @config.user_key = ->(controller) { controller.current_user_key }
232
+ session = {}
233
+ view = metadata_view(user_key: "u1", session: session)
234
+ html = view.picoruby_include_tag(
235
+ source: :local_dist, base_styles: false,
236
+ data: { funicular_epoch: "evil-symbol",
237
+ "funicular_user_key" => "evil-string",
238
+ "funicular-application-id" => "evil-dashed",
239
+ turbo_track: "reload" })
240
+ refute_includes html, "evil-symbol"
241
+ refute_includes html, "evil-string"
242
+ refute_includes html, "evil-dashed"
243
+ assert_includes html, 'data-funicular-user-key="u1"'
244
+ assert_includes html, 'data-funicular-application-id="funicular"'
245
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
246
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
247
+ # The caller's unrelated attribute still rides along, and no
248
+ # attribute is emitted twice.
249
+ assert_includes html, 'data-turbo-track="reload"'
250
+ assert_equal 1, html.scan("data-funicular-epoch=").size
251
+ assert_equal 1, html.scan("data-funicular-user-key=").size
252
+ assert_equal 1, html.scan("data-funicular-application-id=").size
253
+ end
254
+
255
+ def test_caller_data_cannot_enable_the_disabled_local_database
256
+ html = @view.picoruby_include_tag(
257
+ source: :local_dist, base_styles: false,
258
+ data: { funicular_local_database: "true",
259
+ "funicular-application-id" => "injected",
260
+ funicular_anonymous_only: "true" })
261
+ refute_includes html, "data-funicular-local-database"
262
+ refute_includes html, "data-funicular-application-id"
263
+ refute_includes html, "data-funicular-anonymous-only"
264
+ end
265
+
266
+ def test_top_level_options_cannot_override_the_framework_metadata
267
+ # The contract values can bypass the data: hash entirely and arrive
268
+ # as top-level options, in any spelling the tag builder accepts.
269
+ @config.local_database = true
270
+ @config.user_key = ->(controller) { controller.current_user_key }
271
+ session = {}
272
+ view = metadata_view(user_key: "u1", session: session)
273
+ html = view.picoruby_include_tag(
274
+ source: :local_dist, base_styles: false,
275
+ **{ "data-funicular-epoch" => "evil-dashed",
276
+ :"data-funicular-user-key" => "evil-symbol",
277
+ data_funicular_application_id: "evil-underscored",
278
+ "data-turbo-track" => "reload" })
279
+ refute_includes html, "evil-dashed"
280
+ refute_includes html, "evil-symbol"
281
+ refute_includes html, "evil-underscored"
282
+ assert_includes html, 'data-funicular-user-key="u1"'
283
+ assert_includes html, 'data-funicular-application-id="funicular"'
284
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
285
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
286
+ # Unrelated top-level data attributes are the caller's business.
287
+ assert_includes html, 'data-turbo-track="reload"'
288
+ assert_equal 1, html.scan("data-funicular-epoch=").size
289
+ assert_equal 1, html.scan("data-funicular-user-key=").size
290
+ assert_equal 1, html.scan("data-funicular-application-id=").size
291
+ end
292
+
293
+ def test_top_level_options_cannot_enable_the_disabled_local_database
294
+ html = @view.picoruby_include_tag(
295
+ source: :local_dist, base_styles: false,
296
+ **{ "data-funicular-local-database" => "true",
297
+ data_funicular_application_id: "injected" })
298
+ refute_includes html, "data-funicular-local-database"
299
+ refute_includes html, "injected"
300
+ end
301
+
66
302
  def test_cdn_source_uses_versioned_jsdelivr_url
67
303
  @config.cdn_version = "1.2.3"
68
304
  html = @view.picoruby_include_tag(source: :cdn, base_styles: false)
@@ -88,6 +88,26 @@ class SchemaDerivationTest < Minitest::Test
88
88
  assert_equal({ "update" => { method: "PATCH", path: "/x/:id" } }, schema[:endpoints])
89
89
  end
90
90
 
91
+ def test_build_skips_validators_for_readonly_attributes
92
+ schema = Funicular::Schema.build(
93
+ Account,
94
+ attributes: {
95
+ # name has a presence validator, but a readonly (server-managed)
96
+ # attribute is never submitted by the client, so its validators
97
+ # must not be inlined at all.
98
+ "name" => { type: "string", readonly: true }
99
+ }
100
+ )
101
+ assert_equal({ type: "string", readonly: true }, schema[:attributes]["name"])
102
+
103
+ # String keys behave the same.
104
+ schema = Funicular::Schema.build(
105
+ Account,
106
+ attributes: { "name" => { "type" => "string", "readonly" => true } }
107
+ )
108
+ refute schema[:attributes]["name"].key?(:validations)
109
+ end
110
+
91
111
  def test_length_range_is_serialized_as_min_and_max
92
112
  klass = Class.new do
93
113
  include ActiveModel::Validations
@@ -99,6 +119,33 @@ class SchemaDerivationTest < Minitest::Test
99
119
  assert_equal({ "minimum" => 2, "maximum" => 10 }, result["bio"]["length"])
100
120
  end
101
121
 
122
+ def test_bare_numericality_keeps_allow_nil
123
+ # numericality with no constraints serializes to a bare true; the
124
+ # allow_nil flag must upgrade it to a Hash instead of being dropped.
125
+ klass = Class.new do
126
+ include ActiveModel::Validations
127
+ def self.name = "Aged"
128
+ attr_accessor :age
129
+ validates :age, numericality: true, allow_nil: true
130
+ end
131
+ result = Funicular::Schema.validations_for(klass, ["age"])
132
+ assert_equal({ "allow_nil" => true }, result["age"]["numericality"])
133
+ end
134
+
135
+ def test_format_unescapes_ruby_hash_escape
136
+ # Ruby regexp literals escape "#" to suppress interpolation and the escape
137
+ # survives in Regexp#source; JS RegExp under the u flag rejects "\#" as an
138
+ # invalid identity escape (URI::MailTo::EMAIL_REGEXP is the wild example).
139
+ klass = Class.new do
140
+ include ActiveModel::Validations
141
+ def self.name = "Mailish"
142
+ attr_accessor :email
143
+ validates :email, format: { with: /\A[a-z.!\#$%&]+\z/ }
144
+ end
145
+ result = Funicular::Schema.validations_for(klass, ["email"])
146
+ assert_equal "^[a-z.!#$%&]+$", result["email"]["format"]["with"]
147
+ end
148
+
102
149
  def test_format_multiline_flag_is_translated
103
150
  klass = Class.new do
104
151
  include ActiveModel::Validations
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises Funicular::SessionEpoch, the server half of the session
6
+ # epoch (docs decision 13): identity encoding, rotation on every
7
+ # authentication transition, stability while the identity holds, and
8
+ # per-application_id isolation inside one shared Rails session.
9
+ class SessionEpochTest < Minitest::Test
10
+ FakeController = Struct.new(:current_user_key)
11
+
12
+ def setup
13
+ @config = Funicular::Configuration.new
14
+ Funicular.instance_variable_set(:@configuration, @config)
15
+ @session = {}
16
+ end
17
+
18
+ def teardown
19
+ Funicular.instance_variable_set(:@configuration, nil)
20
+ end
21
+
22
+ def controller_for(key)
23
+ FakeController.new(key)
24
+ end
25
+
26
+ def configure_user_key
27
+ @config.user_key = ->(controller) { controller&.current_user_key }
28
+ end
29
+
30
+ def stamp(key)
31
+ Funicular::SessionEpoch.stamp!(@session, controller_for(key))
32
+ end
33
+
34
+ # --- identity ---------------------------------------------------------
35
+
36
+ def test_identity_is_the_typed_versioned_tuple
37
+ configure_user_key
38
+ assert_equal '["v1","funicular","anonymous"]',
39
+ Funicular::SessionEpoch.identity(controller_for(nil))
40
+ assert_equal '["v1","funicular","user","u1"]',
41
+ Funicular::SessionEpoch.identity(controller_for("u1"))
42
+ end
43
+
44
+ def test_user_key_is_canonicalized_with_to_s
45
+ configure_user_key
46
+ assert_equal '["v1","funicular","user","42"]',
47
+ Funicular::SessionEpoch.identity(controller_for(42))
48
+ end
49
+
50
+ def test_a_user_key_of_anonymous_cannot_collide_with_signed_out
51
+ configure_user_key
52
+ refute_equal Funicular::SessionEpoch.identity(controller_for(nil)),
53
+ Funicular::SessionEpoch.identity(controller_for("anonymous"))
54
+ end
55
+
56
+ def test_without_a_user_key_lambda_everyone_is_anonymous
57
+ assert_equal '["v1","funicular","anonymous"]',
58
+ Funicular::SessionEpoch.identity(controller_for("u1"))
59
+ end
60
+
61
+ def test_empty_resolved_user_key_fails_loud
62
+ configure_user_key
63
+ error = assert_raises(Funicular::Error) { stamp("") }
64
+ assert_includes error.message, "empty"
65
+ end
66
+
67
+ # --- rotation ---------------------------------------------------------
68
+
69
+ def test_stamp_creates_and_then_holds_an_epoch
70
+ configure_user_key
71
+ epoch = stamp("u1")
72
+ refute_empty epoch
73
+ assert_equal epoch, stamp("u1")
74
+ assert_equal epoch, stamp("u1")
75
+ end
76
+
77
+ def test_login_logout_and_switch_all_rotate
78
+ configure_user_key
79
+ anonymous = stamp(nil)
80
+ login = stamp("u1")
81
+ refute_equal anonymous, login
82
+ switch = stamp("u2")
83
+ refute_equal login, switch
84
+ logout = stamp(nil)
85
+ refute_equal switch, logout
86
+ # A fresh anonymous epoch, not the first one resurrected.
87
+ refute_equal anonymous, logout
88
+ end
89
+
90
+ # --- per-application isolation ----------------------------------------
91
+
92
+ def test_epochs_are_tracked_per_application_id
93
+ configure_user_key
94
+ epoch_a = stamp("u1")
95
+ @config.application_id = "second_app"
96
+ epoch_b = stamp("u1")
97
+ refute_equal epoch_a, epoch_b
98
+ # Rotating the second app's epoch leaves the first app's alone.
99
+ stamp("u2")
100
+ @config.application_id = "funicular"
101
+ assert_equal epoch_a, stamp("u1")
102
+ end
103
+
104
+ # --- session shape ------------------------------------------------------
105
+
106
+ def test_session_entry_uses_string_keys_for_json_round_trips
107
+ configure_user_key
108
+ epoch = stamp("u1")
109
+ entry = @session["funicular_epochs"]["funicular"]
110
+ assert_equal '["v1","funicular","user","u1"]', entry["identity"]
111
+ assert_equal epoch, entry["epoch"]
112
+ end
113
+
114
+ def test_a_corrupt_session_value_is_replaced_not_crashed_on
115
+ configure_user_key
116
+ @session["funicular_epochs"] = "garbage"
117
+ epoch = stamp("u1")
118
+ refute_empty epoch
119
+ assert_equal epoch,
120
+ @session["funicular_epochs"]["funicular"]["epoch"]
121
+ end
122
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The SSR contract for the local database (docs decision 18): model
6
+ # class bodies evaluate under CRuby -- declarations are recorded, no
7
+ # SQLite is touched -- but booting the database or materializing a
8
+ # local query on the server fails loud with UnavailableError, never an
9
+ # empty result.
10
+ class SSRDatabaseTest < Minitest::Test
11
+ def setup
12
+ # Loads the mrblib runtime under CRuby and flips Funicular.server
13
+ # to true, exactly like a Rails SSR render.
14
+ Funicular::SSR::Runtime.load_framework!
15
+ end
16
+
17
+ def test_boot_refuses_on_the_server
18
+ error = assert_raises(Funicular::DB::UnavailableError) do
19
+ Funicular::DB.boot(models: [], metadata: {})
20
+ end
21
+ assert_includes error.message, "SSR"
22
+ # The refusal is a clean raise, not a failed state: nothing to
23
+ # tear down, nothing latched.
24
+ assert_equal :unbooted, Funicular::DB.boot_state
25
+ end
26
+
27
+ def test_a_local_class_body_evaluates_but_materializing_raises
28
+ model = Class.new(Funicular::Model) do
29
+ table_name "ssr_drafts"
30
+ storage :local do
31
+ migrate 1 do |t|
32
+ t.string :title
33
+ end
34
+ end
35
+ end
36
+ # The declaration side worked: storage recorded, no SQLite touched.
37
+ assert_equal true, model.local?
38
+ # Materializing is where the server says no.
39
+ assert_raises(Funicular::DB::UnavailableError) { model.count }
40
+ assert_raises(Funicular::DB::UnavailableError) { model.local.to_a }
41
+ assert_raises(Funicular::DB::UnavailableError) do
42
+ model.local_create(title: "nope")
43
+ end
44
+ end
45
+
46
+ def test_association_declarations_evaluate_but_reading_raises
47
+ # Named constants, because the conventions read the class name:
48
+ # SsrPost -> ssr_post_id, :ssr_comment -> SsrComment.
49
+ Object.const_set(:SsrComment, Class.new(Funicular::Model) do
50
+ storage :local do
51
+ migrate 1 do |t|
52
+ t.integer :ssr_post_id
53
+ end
54
+ end
55
+ end)
56
+ Object.const_set(:SsrPost, Class.new(Funicular::Model))
57
+ # Declared after the constant exists, the way a `class SsrPost <
58
+ # Funicular::Model` body reads: has_many derives its foreign key
59
+ # from the declaring class name.
60
+ SsrPost.class_eval do
61
+ storage :local do
62
+ migrate 1 do |t|
63
+ t.integer :ssr_comment_id
64
+ end
65
+ end
66
+ belongs_to :ssr_comment
67
+ has_many :ssr_comments
68
+ end
69
+ # The declaration side worked: nothing resolved, no SQLite touched.
70
+ record = SsrPost.new(ssr_comment_id: 1)
71
+ # Reading one is a local query, and the server has no local database.
72
+ assert_raises(Funicular::DB::UnavailableError) { record.ssr_comment }
73
+ assert_raises(Funicular::DB::UnavailableError) { record.ssr_comments.to_a }
74
+ ensure
75
+ Object.send(:remove_const, :SsrPost) if Object.const_defined?(:SsrPost)
76
+ Object.send(:remove_const, :SsrComment) if Object.const_defined?(:SsrComment)
77
+ end
78
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+
8
+ # Development-mode SSR reload: with auto_reload enabled (the railtie
9
+ # turns it on in development), editing an app source makes the next
10
+ # boot! re-load the sources, so server-rendered markup matches what the
11
+ # recompiled client bundle will hydrate.
12
+ class SSRReloadTest < Minitest::Test
13
+ def setup
14
+ Funicular::SSR::Runtime.load_framework!
15
+ end
16
+
17
+ def teardown
18
+ Funicular::SSR::Runtime.auto_reload = false
19
+ Funicular::SSR::Runtime.reset_app!
20
+ end
21
+
22
+ def write_component(dir, title)
23
+ File.write(File.join(dir, "components", "reload_probe_component.rb"), <<~RUBY)
24
+ class ReloadProbeComponent < Funicular::Component
25
+ def render
26
+ div { "#{title}" }
27
+ end
28
+ end
29
+ RUBY
30
+ end
31
+
32
+ def build_app(dir)
33
+ FileUtils.mkdir_p(File.join(dir, "components"))
34
+ write_component(dir, "before reload")
35
+ File.write(File.join(dir, "initializer.rb"), <<~RUBY)
36
+ Funicular.start(container: "app") do |router|
37
+ router.get("/probe", to: ReloadProbeComponent, as: "probe")
38
+ end
39
+ RUBY
40
+ end
41
+
42
+ def render_probe(dir)
43
+ Funicular::SSR.render(path: "/probe", source_dir: dir)[:html]
44
+ end
45
+
46
+ def quietly
47
+ original_verbose = $VERBOSE
48
+ $VERBOSE = nil
49
+ yield
50
+ ensure
51
+ $VERBOSE = original_verbose
52
+ end
53
+
54
+ def test_edited_sources_are_reloaded_when_auto_reload_is_on
55
+ Dir.mktmpdir do |dir|
56
+ build_app(dir)
57
+ Funicular::SSR::Runtime.reset_app!
58
+ assert_includes render_probe(dir), "before reload"
59
+
60
+ write_component(dir, "after reload")
61
+ bump_mtime(dir)
62
+
63
+ # Off (the default): still the stale markup.
64
+ assert_includes render_probe(dir), "before reload"
65
+
66
+ Funicular::SSR::Runtime.auto_reload = true
67
+ quietly do
68
+ assert_includes render_probe(dir), "after reload"
69
+ end
70
+ end
71
+ end
72
+
73
+ def test_reset_app_discards_the_snapshot
74
+ Dir.mktmpdir do |dir|
75
+ build_app(dir)
76
+ Funicular::SSR::Runtime.reset_app!
77
+ quietly { render_probe(dir) }
78
+ refute Funicular::SSR::Runtime.sources_changed?(dir)
79
+
80
+ Funicular::SSR::Runtime.reset_app!
81
+ assert Funicular::SSR::Runtime.sources_changed?(dir)
82
+ end
83
+ end
84
+
85
+ def test_unchanged_sources_are_not_reloaded
86
+ Dir.mktmpdir do |dir|
87
+ build_app(dir)
88
+ Funicular::SSR::Runtime.reset_app!
89
+ Funicular::SSR::Runtime.auto_reload = true
90
+ quietly { render_probe(dir) }
91
+
92
+ refute Funicular::SSR::Runtime.sources_changed?(dir)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # mtime comparison, not equality of content: make the edit observable
99
+ # even on filesystems with coarse timestamps.
100
+ def bump_mtime(dir)
101
+ future = Time.now + 2
102
+ Dir.glob(File.join(dir, "**", "*.rb")).each do |file|
103
+ File.utime(future, future, file)
104
+ end
105
+ end
106
+ end
data/minitest/ssr_test.rb CHANGED
@@ -122,6 +122,47 @@ class SSRTest < Minitest::Test
122
122
  assert_includes result[:html], "greeting"
123
123
  end
124
124
 
125
+ # --- Single-component render (no route lookup) ------------------------
126
+
127
+ def test_render_component_renders_by_name_without_a_route
128
+ html = Funicular::SSR.render_component(
129
+ "GreetingComponent",
130
+ state: { title: "Embedded" },
131
+ source_dir: APP_DIR
132
+ )
133
+ assert_includes html, "<h1>Embedded</h1>"
134
+ assert_includes html, 'class="greeting"'
135
+ end
136
+
137
+ def test_render_component_passes_props_and_state
138
+ html = Funicular::SSR.render_component(
139
+ "ProbeComponent",
140
+ props: { "label" => "From Props" },
141
+ state: { note: "injected note" },
142
+ source_dir: APP_DIR
143
+ )
144
+ assert_includes html, "<h2>From Props</h2>"
145
+ assert_includes html, "<p>injected note</p>"
146
+ end
147
+
148
+ def test_render_component_uses_initialize_state_without_injection
149
+ html = Funicular::SSR.render_component("GreetingComponent", source_dir: APP_DIR)
150
+ assert_includes html, "<h1>Default Title</h1>"
151
+ end
152
+
153
+ def test_render_component_raises_on_unknown_component
154
+ assert_raises(NameError) do
155
+ Funicular::SSR.render_component("NoSuchComponent", source_dir: APP_DIR)
156
+ end
157
+ end
158
+
159
+ def test_render_component_rejects_a_non_component_constant
160
+ error = assert_raises(ArgumentError) do
161
+ Funicular::SSR.render_component("String", source_dir: APP_DIR)
162
+ end
163
+ assert_match(/not a Funicular::Component/, error.message)
164
+ end
165
+
125
166
  def test_symbolize_keys_handles_nil_and_string_keys
126
167
  assert_equal({}, Funicular::SSR.symbolize_keys(nil))
127
168
  assert_equal({ a: 1, b: 2 }, Funicular::SSR.symbolize_keys("a" => 1, "b" => 2))