funicular 0.3.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Funicular
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/funicular.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "funicular/version"
4
4
  require_relative "funicular/configuration"
5
+ require_relative "funicular/session_epoch"
5
6
  require_relative "funicular/compiler"
6
7
  require_relative "funicular/plugin"
7
8
  require_relative "funicular/schema"
@@ -1,25 +1,24 @@
1
1
  class FunicularChatComponent < Funicular::Component
2
- styles do |css|
3
- css.define :shell, "funicular-chat"
4
- css.define :panel, "funicular-chat__panel"
5
- css.define :header, "funicular-chat__header"
6
- css.define :title, "funicular-chat__title"
7
- css.define :subtitle, "funicular-chat__subtitle"
8
- css.define :messages, "funicular-chat__messages"
9
- css.define :empty, "funicular-chat__empty"
10
- css.define :message, "funicular-chat__message"
11
- css.define :name, "funicular-chat__message-name"
12
- css.define :body, "funicular-chat__message-body"
13
- css.define :form, "funicular-chat__form"
14
- css.define :row, "funicular-chat__form-row"
15
- css.define :input, "funicular-chat__input"
16
- css.define :button,
17
- base: "funicular-chat__button",
18
- variants: {
19
- enabled: "funicular-chat__button--enabled",
20
- disabled: "funicular-chat__button--disabled"
21
- }
22
- css.define :error, "funicular-chat__error"
2
+ styles do
3
+ shell "funicular-chat"
4
+ panel "funicular-chat__panel"
5
+ header "funicular-chat__header"
6
+ title "funicular-chat__title"
7
+ subtitle "funicular-chat__subtitle"
8
+ messages "funicular-chat__messages"
9
+ empty "funicular-chat__empty"
10
+ message "funicular-chat__message"
11
+ name "funicular-chat__message-name"
12
+ body "funicular-chat__message-body"
13
+ form "funicular-chat__form"
14
+ row "funicular-chat__form-row"
15
+ input "funicular-chat__input"
16
+ button base: "funicular-chat__button",
17
+ variants: {
18
+ enabled: "funicular-chat__button--enabled",
19
+ disabled: "funicular-chat__button--disabled"
20
+ }
21
+ error "funicular-chat__error"
23
22
  end
24
23
 
25
24
  def initialize_state
@@ -95,37 +94,37 @@ class FunicularChatComponent < Funicular::Component
95
94
  end
96
95
  end
97
96
 
98
- def render(h)
99
- h.div(class: h.styles[:shell]) do |hh|
100
- hh.div(class: h.styles[:panel]) do |panel|
101
- panel.div(class: h.styles[:header]) do |header|
102
- header.h1(class: h.styles[:title]) { "Funicular Chat" }
103
- header.p(class: h.styles[:subtitle]) do
97
+ def render
98
+ div(class: styles.shell) do
99
+ div(class: styles.panel) do
100
+ div(class: styles.header) do
101
+ h1(class: styles.title) { "Funicular Chat" }
102
+ p(class: styles.subtitle) do
104
103
  state[:connected] ? "Realtime Ruby chat over ActionCable" : "Connecting..."
105
104
  end
106
105
  end
107
106
 
108
- panel.div(class: h.styles[:messages]) do |messages|
107
+ div(class: styles.messages) do
109
108
  if state[:messages].empty?
110
- messages.p(class: h.styles[:empty]) { "No messages yet." }
109
+ p(class: styles.empty) { "No messages yet." }
111
110
  else
112
111
  state[:messages].each do |message|
113
- messages.div(class: h.styles[:message], key: message["id"]) do |item|
114
- item.div(class: h.styles[:name]) { message["name"] }
115
- item.div(class: h.styles[:body]) { message["body"] }
112
+ div(class: styles.message, key: message["id"]) do
113
+ div(class: styles.name) { message["name"] }
114
+ div(class: styles.body) { message["body"] }
116
115
  end
117
116
  end
118
117
  end
119
118
  end
120
119
 
121
- panel.form(onsubmit: :send_message, class: h.styles[:form]) do |form|
120
+ form(onsubmit: :send_message, class: styles.form) do
122
121
  if state[:error]
123
- form.div(class: h.styles[:error]) { state[:error] }
122
+ div(class: styles.error) { state[:error] }
124
123
  end
125
- form.div(class: h.styles[:row]) do |row|
126
- row.input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: h.styles[:input])
127
- row.input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: h.styles[:input])
128
- row.button(type: "submit", class: h.styles[:button, state[:sending] ? :disabled : :enabled], disabled: state[:sending]) do
124
+ div(class: styles.row) do
125
+ input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: styles.input)
126
+ input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: styles.input)
127
+ button(type: "submit", class: styles.button(state[:sending] ? :disabled : :enabled), disabled: state[:sending]) do
129
128
  state[:sending] ? "Sending..." : "Send"
130
129
  end
131
130
  end
@@ -101,12 +101,20 @@ namespace :funicular do
101
101
 
102
102
  FileUtils.cp(source_js, dest_js)
103
103
  FileUtils.cp(source_css, dest_css)
104
- FileUtils.cp(source_initializer, dest_initializer)
105
104
 
106
105
  puts "Installed Funicular debug assets:"
107
106
  puts " - #{dest_js}"
108
107
  puts " - #{dest_css}"
109
- puts " - #{dest_initializer}"
108
+
109
+ # The initializer belongs to the application once it exists:
110
+ # re-running the installer must never clobber its configuration
111
+ # (source choice, local_database opt-in, user_key).
112
+ if File.exist?(dest_initializer)
113
+ puts "Skipped #{dest_initializer} (exists; delete it first to reinstall the template)"
114
+ else
115
+ FileUtils.cp(source_initializer, dest_initializer)
116
+ puts " - #{dest_initializer}"
117
+ end
110
118
  end
111
119
 
112
120
  desc "Install vendored PicoRuby.wasm artifacts (dist + debug) into public/picoruby/"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The mrblib runtime is plain Ruby, so the CRuby suite can cover the
6
+ # Response#body alias and the handler error report format directly;
7
+ # the browser-side wiring ships with the next picoruby.wasm rebuild.
8
+ class CallbackErrorVisibilityTest < Minitest::Test
9
+ def setup
10
+ Funicular::SSR::Runtime.load_framework!
11
+ end
12
+
13
+ # Assigning the anonymous class to a constant names it, so the report
14
+ # prints a real component name. Defined lazily: Funicular::Component
15
+ # only exists after the framework loads.
16
+ def probe_component
17
+ unless defined?(::CheckoutProbeComponent)
18
+ Object.const_set(:CheckoutProbeComponent, Class.new(Funicular::Component) do
19
+ def render; end
20
+ end)
21
+ end
22
+ ::CheckoutProbeComponent.new
23
+ end
24
+
25
+ def test_response_body_is_an_alias_of_data
26
+ response = Funicular::HTTP::Response.new(200, { "id" => 1 })
27
+ assert_equal response.data, response.body
28
+ assert_equal({ "id" => 1 }, response.body)
29
+ end
30
+
31
+ def test_report_handler_error_names_component_handler_and_event
32
+ error = ArgumentError.new("wrong number of arguments (given 1, expected 0)")
33
+
34
+ out, _err = capture_io do
35
+ probe_component.report_handler_error("click", "#handle_save", error)
36
+ end
37
+
38
+ assert_includes out, "CheckoutProbeComponent#handle_save"
39
+ assert_includes out, "(onclick)"
40
+ assert_includes out, "ArgumentError: wrong number of arguments"
41
+ end
42
+
43
+ def test_report_handler_error_never_raises
44
+ capture_io do
45
+ assert_nil probe_component.report_handler_error("click", "#x", RuntimeError.new("y"))
46
+ end
47
+ end
48
+ end
@@ -61,4 +61,82 @@ class ConfigurationTest < Minitest::Test
61
61
  assert_equal vendored, @config.cdn_version
62
62
  end
63
63
  end
64
+
65
+ # --- local-database namespace settings (docs decisions 12/13) ---------
66
+
67
+ def test_namespace_defaults
68
+ assert_equal false, @config.local_database
69
+ assert_equal "funicular", @config.application_id
70
+ assert_nil @config.user_key
71
+ assert_equal false, @config.anonymous_only
72
+ end
73
+
74
+ def test_local_database_setter_normalizes_truthiness
75
+ @config.local_database = Object.new
76
+ assert_equal true, @config.local_database
77
+ @config.local_database = nil
78
+ assert_equal false, @config.local_database
79
+ end
80
+
81
+ def test_disabled_local_database_needs_no_identity
82
+ assert_equal true, @config.validate_local_database!
83
+ end
84
+
85
+ def test_enabled_local_database_requires_an_identity_declaration
86
+ @config.local_database = true
87
+ error = assert_raises(ArgumentError) do
88
+ @config.validate_local_database!
89
+ end
90
+ assert_includes error.message, "user_key"
91
+ assert_includes error.message, "anonymous_only"
92
+ end
93
+
94
+ def test_either_identity_declaration_satisfies_local_database
95
+ @config.local_database = true
96
+ @config.anonymous_only = true
97
+ assert_equal true, @config.validate_local_database!
98
+
99
+ fresh = Funicular::Configuration.new
100
+ fresh.local_database = true
101
+ fresh.user_key = ->(_controller) { nil }
102
+ assert_equal true, fresh.validate_local_database!
103
+ end
104
+
105
+ def test_application_id_rejects_empty_values
106
+ error = assert_raises(ArgumentError) { @config.application_id = "" }
107
+ assert_includes error.message, "application_id"
108
+ error = assert_raises(ArgumentError) { @config.application_id = nil }
109
+ assert_includes error.message, "application_id"
110
+ end
111
+
112
+ def test_application_id_is_canonicalized_with_to_s
113
+ @config.application_id = :chat_app
114
+ assert_equal "chat_app", @config.application_id
115
+ end
116
+
117
+ def test_user_key_must_be_callable
118
+ error = assert_raises(ArgumentError) { @config.user_key = "not callable" }
119
+ assert_includes error.message, "callable"
120
+ end
121
+
122
+ def test_user_key_and_anonymous_only_are_mutually_exclusive
123
+ # The reliably-detectable server-side config error: whichever
124
+ # setter comes second raises, in both orders.
125
+ @config.user_key = ->(controller) { nil }
126
+ error = assert_raises(ArgumentError) { @config.anonymous_only = true }
127
+ assert_includes error.message, "mutually exclusive"
128
+
129
+ fresh = Funicular::Configuration.new
130
+ fresh.anonymous_only = true
131
+ error = assert_raises(ArgumentError) do
132
+ fresh.user_key = ->(controller) { nil }
133
+ end
134
+ assert_includes error.message, "mutually exclusive"
135
+ end
136
+
137
+ def test_anonymous_only_false_never_conflicts
138
+ @config.user_key = ->(controller) { nil }
139
+ @config.anonymous_only = false
140
+ assert_equal false, @config.anonymous_only
141
+ end
64
142
  end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Bareword component DSL (0.4.0), CRuby/SSR side. Twin of test/dsl_test.rb;
6
+ # the attr_accessor case differs by VM: CRuby fires method_added for attr_*
7
+ # so the collision surfaces at class-definition time, while mruby relies on
8
+ # the mount-time sweep.
9
+ class DSLTest < Minitest::Test
10
+ def setup
11
+ Funicular::SSR::Runtime.load_framework!
12
+ end
13
+
14
+ def test_bareword_render_builds_nested_tree
15
+ klass = Class.new(Funicular::Component) do
16
+ def initialize_state
17
+ { items: ["a", "b"] }
18
+ end
19
+
20
+ def render
21
+ div(class: "list") do
22
+ h1 { "title" }
23
+ ul do
24
+ state[:items].each_with_index do |item, i|
25
+ li(key: i) { item }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ vnode = klass.new.build_vdom
33
+
34
+ assert_equal "div", vnode.tag
35
+ assert_equal ["h1", "ul"], vnode.children.map(&:tag)
36
+ assert_equal 2, vnode.children[1].children.size
37
+ end
38
+
39
+ def test_defining_reserved_tag_name_raises_at_class_definition
40
+ assert_raises(Funicular::DSLCollisionError) do
41
+ Class.new(Funicular::Component) do
42
+ def label
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def test_attr_accessor_collision_raises_at_class_definition_on_cruby
49
+ # CRuby fires method_added for attr_*, so this is caught immediately
50
+ # (on mruby the mount-time sweep catches it instead).
51
+ assert_raises(Funicular::DSLCollisionError) do
52
+ Class.new(Funicular::Component) do
53
+ attr_accessor :label
54
+ end
55
+ end
56
+ end
57
+
58
+ def test_included_module_collision_is_caught_at_first_build
59
+ mod = Module.new do
60
+ def select
61
+ end
62
+ end
63
+ klass = Class.new(Funicular::Component) do
64
+ def render
65
+ div { "x" }
66
+ end
67
+ end
68
+ klass.include(mod)
69
+
70
+ assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
71
+ end
72
+
73
+ def test_allow_dsl_override_restores_user_method_and_tag_escape_hatch
74
+ klass = Class.new(Funicular::Component) do
75
+ allow_dsl_override :label
76
+
77
+ def label
78
+ "custom"
79
+ end
80
+
81
+ def render
82
+ div { tag(:label) { label } }
83
+ end
84
+ end
85
+
86
+ vnode = klass.new.build_vdom
87
+
88
+ assert_equal "label", vnode.children.first.tag
89
+ assert_equal ["custom"], vnode.children.first.children
90
+ end
91
+
92
+ def test_render_with_parameter_raises_migration_error
93
+ klass = Class.new(Funicular::Component) do
94
+ def render(h)
95
+ h
96
+ end
97
+ end
98
+
99
+ error = assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
100
+ assert_match(/must not take parameters/, error.message)
101
+ end
102
+
103
+ def test_p_with_non_hash_raises_with_hint
104
+ klass = Class.new(Funicular::Component) do
105
+ def render
106
+ div { p "debug me" }
107
+ end
108
+ end
109
+
110
+ error = assert_raises(ArgumentError) { klass.new.build_vdom }
111
+ assert_match(/puts x.inspect/, error.message)
112
+ end
113
+
114
+ def test_p_with_hash_builds_paragraph
115
+ klass = Class.new(Funicular::Component) do
116
+ def render
117
+ p(class: "para") { "text" }
118
+ end
119
+ end
120
+
121
+ vnode = klass.new.build_vdom
122
+
123
+ assert_equal "p", vnode.tag
124
+ assert_equal ["text"], vnode.children
125
+ end
126
+
127
+ def test_tag_outside_render_raises
128
+ klass = Class.new(Funicular::Component) do
129
+ def render
130
+ div { "x" }
131
+ end
132
+ end
133
+ component = klass.new
134
+ component.build_vdom
135
+
136
+ assert_raises(Funicular::RenderContextError) { component.div { "outside" } }
137
+ end
138
+
139
+ def test_bareword_styles_definition_and_method_access
140
+ klass = Class.new(Funicular::Component) do
141
+ styles do
142
+ shell "app-shell"
143
+ display "display-class"
144
+ button base: "btn", variants: { primary: "btn--primary" }, active: "btn--on"
145
+ end
146
+
147
+ def render
148
+ div(class: styles.shell) do
149
+ span(class: styles.display) { "d" }
150
+ button(class: styles.button(:primary)) { "b" }
151
+ a(class: styles[:button, true] | "extra") { "x" }
152
+ end
153
+ end
154
+ end
155
+
156
+ vnode = klass.new.build_vdom
157
+
158
+ assert_equal "app-shell", vnode.props[:class]
159
+ # :display would resolve to Object#display on an Object-based accessor;
160
+ # the BasicObject accessor keeps it a plain style name under CRuby SSR,
161
+ # matching the mruby client.
162
+ assert_equal "display-class", vnode.children[0].props[:class]
163
+ assert_equal "btn btn--primary", vnode.children[1].props[:class]
164
+ assert_equal "btn btn--on extra", vnode.children[2].props[:class]
165
+ end
166
+
167
+ def test_style_value_behaves_like_the_string_it_wraps
168
+ klass = Class.new(Funicular::Component) do
169
+ styles do
170
+ field "field-base"
171
+ end
172
+
173
+ def render
174
+ div(class: styles.field + " col-span-2") { "x" }
175
+ end
176
+ end
177
+
178
+ vnode = klass.new.build_vdom
179
+ assert_equal "field-base col-span-2", vnode.props[:class]
180
+
181
+ value = klass.new.styles.field
182
+ combined = value + "!"
183
+ assert_kind_of Funicular::StyleValue, combined
184
+ assert_equal "field-base!", combined.to_s
185
+ assert_equal "field-basefield-base", (value + value).to_s
186
+ # Like String#+: non-String operands raise instead of being to_s-ed.
187
+ assert_raises(TypeError) { value + 123 }
188
+ # No to_str on purpose: the mruby client's String#+ never coerces,
189
+ # so CRuby must reject the reversed form the same way the browser
190
+ # does instead of letting SSR accept code the client raises on.
191
+ assert_raises(TypeError) { "prefix " + value }
192
+ end
193
+
194
+ def test_unknown_style_raises
195
+ klass = Class.new(Funicular::Component) do
196
+ styles do
197
+ shell "app-shell"
198
+ end
199
+
200
+ def render
201
+ div { "x" }
202
+ end
203
+ end
204
+ component = klass.new
205
+
206
+ assert_raises(NoMethodError) { component.styles.typo_name }
207
+ assert_raises(ArgumentError) { component.styles[:typo] }
208
+ end
209
+
210
+ def test_bareword_helper_call_inside_styles_block_raises
211
+ assert_raises(ArgumentError) do
212
+ Class.new(Funicular::Component) do
213
+ styles do
214
+ button base: helper_that_does_not_exist("x")
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ def test_duplicate_style_definition_raises
221
+ assert_raises(ArgumentError) do
222
+ Class.new(Funicular::Component) do
223
+ styles do
224
+ shell "a"
225
+ shell "b"
226
+ end
227
+ end
228
+ end
229
+ end
230
+
231
+ def test_suspense_fallback_runs_bareword_in_own_component
232
+ klass = Class.new(Funicular::Component) do
233
+ use_suspense :user, ->(resolve, _reject) { resolve.call("u") }
234
+
235
+ def render
236
+ div do
237
+ suspense(:user, fallback: -> { span { "loading" } }) do |res|
238
+ span { res[:user] }
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ component = klass.new
245
+ vnode = component.build_vdom
246
+ assert_equal "loading", vnode.children.first.children.first
247
+
248
+ component.instance_variable_get(:@suspense_data)[:user] = "u"
249
+ component.instance_variable_get(:@suspense_states)[:user] = :resolved
250
+ vnode = component.build_vdom
251
+ assert_equal "u", vnode.children.first.children.first
252
+ end
253
+
254
+ def test_error_boundary_fallback_receives_view_context
255
+ fallback = ->(h, error) { h.div(class: "custom-error") { error.message } }
256
+ boundary = Funicular::ErrorBoundary.new(fallback: fallback)
257
+ boundary.catch_error(RuntimeError.new("boom"))
258
+ vnode = boundary.build_vdom
259
+
260
+ assert_equal "div", vnode.tag
261
+ assert_equal "custom-error", vnode.props[:class]
262
+ assert_equal ["boom"], vnode.children
263
+ end
264
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "funicular/epoch_header"
5
+
6
+ # Exercises Funicular::EpochHeader, the middleware that writes
7
+ # X-Funicular-Epoch on every outgoing response: from the env value the
8
+ # controller concern pinned, from the stored session entry when the
9
+ # request died before the concern ran, or not at all when neither
10
+ # exists.
11
+ class EpochHeaderTest < Minitest::Test
12
+ # Lowercase: the Rack 3 spec requires lowercase response header
13
+ # names, and HTTP header lookups are case-insensitive client-side.
14
+ HEADER = "x-funicular-epoch"
15
+
16
+ def setup
17
+ @config = Funicular::Configuration.new
18
+ @config.local_database = true
19
+ Funicular.instance_variable_set(:@configuration, @config)
20
+ end
21
+
22
+ def teardown
23
+ Funicular.instance_variable_set(:@configuration, nil)
24
+ end
25
+
26
+ def app_returning(status, headers = {})
27
+ ->(_env) { [status, headers, ["body"]] }
28
+ end
29
+
30
+ def test_writes_the_env_pinned_epoch
31
+ middleware = Funicular::EpochHeader.new(app_returning(200))
32
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
33
+ assert_equal "e-ctrl", headers[HEADER]
34
+ end
35
+
36
+ def test_disabled_local_database_leaves_the_response_untouched
37
+ @config.local_database = false
38
+ headers = { HEADER => "inner", "X-Funicular-Epoch" => "legacy" }
39
+ middleware = Funicular::EpochHeader.new(app_returning(200, headers))
40
+ _, result, = middleware.call({ "funicular.epoch" => "outer" })
41
+ assert_same headers, result
42
+ assert_equal "inner", result[HEADER]
43
+ assert_equal "legacy", result["X-Funicular-Epoch"]
44
+ end
45
+
46
+ def test_falls_back_to_the_stored_session_epoch
47
+ # The concern never ran (an earlier before_action raised, say),
48
+ # but the session already carries this app's epoch: stamped
49
+ # without rotation.
50
+ session = {
51
+ "funicular_epochs" => {
52
+ "funicular" => { "identity" => "i", "epoch" => "e-stored" },
53
+ },
54
+ }
55
+ middleware = Funicular::EpochHeader.new(app_returning(500))
56
+ _, headers, = middleware.call({ "rack.session" => session })
57
+ assert_equal "e-stored", headers[HEADER]
58
+ end
59
+
60
+ def test_the_env_value_wins_over_the_stored_one
61
+ session = {
62
+ "funicular_epochs" => {
63
+ "funicular" => { "identity" => "i", "epoch" => "e-stored" },
64
+ },
65
+ }
66
+ middleware = Funicular::EpochHeader.new(app_returning(200))
67
+ _, headers, = middleware.call(
68
+ { "funicular.epoch" => "e-ctrl", "rack.session" => session })
69
+ assert_equal "e-ctrl", headers[HEADER]
70
+ end
71
+
72
+ def test_without_any_epoch_the_header_stays_absent
73
+ middleware = Funicular::EpochHeader.new(app_returning(200))
74
+ _, headers, = middleware.call({})
75
+ refute headers.key?(HEADER)
76
+ end
77
+
78
+ def test_an_inner_header_is_dropped_when_no_epoch_is_available
79
+ # No env value and no session: the contract is "no authoritative
80
+ # epoch, no header". An inner layer's leftover would otherwise
81
+ # survive as the page's only epoch signal -- and one stamped
82
+ # before the session rotated would read as a MATCH, keeping a page
83
+ # alive that decision 13 wants terminal.
84
+ middleware = Funicular::EpochHeader.new(
85
+ app_returning(200, { HEADER => "stale-pre-login" }))
86
+ _, headers, = middleware.call({})
87
+ refute headers.key?(HEADER)
88
+ end
89
+
90
+ def test_a_legacy_cased_inner_header_is_dropped_without_an_epoch_too
91
+ middleware = Funicular::EpochHeader.new(
92
+ app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
93
+ _, headers, = middleware.call({})
94
+ refute headers.key?("X-Funicular-Epoch")
95
+ refute headers.key?(HEADER)
96
+ end
97
+
98
+ def test_respects_the_configured_application_id
99
+ @config.application_id = "second_app"
100
+ session = {
101
+ "funicular_epochs" => {
102
+ "funicular" => { "identity" => "i", "epoch" => "e-other" },
103
+ "second_app" => { "identity" => "i", "epoch" => "e-mine" },
104
+ },
105
+ }
106
+ middleware = Funicular::EpochHeader.new(app_returning(200))
107
+ _, headers, = middleware.call({ "rack.session" => session })
108
+ assert_equal "e-mine", headers[HEADER]
109
+ end
110
+
111
+ def test_overwrites_a_stale_inner_header
112
+ # An inner layer's header may have been stamped BEFORE a login/
113
+ # logout rotated the epoch; keeping it would let an old page apply
114
+ # the post-transition response as a match. The framework's value
115
+ # is authoritative.
116
+ middleware = Funicular::EpochHeader.new(
117
+ app_returning(200, { HEADER => "stale-pre-login" }))
118
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
119
+ assert_equal "e-ctrl", headers[HEADER]
120
+ end
121
+
122
+ def test_a_legacy_cased_inner_header_cannot_ride_out_as_a_duplicate
123
+ # Plain header hashes are case-sensitive: a stale capitalized
124
+ # spelling must be removed, not merely shadowed by our lowercase
125
+ # (Rack 3) name.
126
+ middleware = Funicular::EpochHeader.new(
127
+ app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
128
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
129
+ assert_equal "e-ctrl", headers[HEADER]
130
+ refute headers.key?("X-Funicular-Epoch")
131
+ end
132
+
133
+ def test_the_header_name_is_lowercase_for_rack_3
134
+ middleware = Funicular::EpochHeader.new(app_returning(200))
135
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
136
+ assert_equal ["x-funicular-epoch"], headers.keys
137
+ end
138
+
139
+ def test_a_broken_session_read_never_masks_the_response
140
+ broken = Object.new
141
+ def broken.[](_key)
142
+ raise "session store exploded"
143
+ end
144
+ middleware = Funicular::EpochHeader.new(app_returning(500))
145
+ status, headers, = middleware.call({ "rack.session" => broken })
146
+ assert_equal 500, status
147
+ refute headers.key?(HEADER)
148
+ end
149
+ end