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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- 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, "">"
|
|
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)
|
data/minitest/schema_test.rb
CHANGED
|
@@ -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))
|