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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +486 -1
- data/demo/local_notes.html +207 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +227 -12
- 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 +58 -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 +655 -574
- 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 +800 -530
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- 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 -6909
- 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/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- 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 +264 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- 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/sig_tags_test.rb +30 -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/minitest/view_context_test.rb +15 -15
- data/mrbgem.rake +2 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +226 -24
- data/mrblib/db.rb +3116 -0
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/form_builder.rb +10 -10
- 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 +122 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +25 -4
- data/sig/db.rbs +328 -0
- data/sig/error_boundary.rbs +4 -4
- 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 +19 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +23 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
|
Binary file
|
data/lib/funicular/version.rb
CHANGED
data/lib/funicular.rb
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
class FunicularChatComponent < Funicular::Component
|
|
2
|
-
styles do
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
+
div(class: styles.messages) do
|
|
109
108
|
if state[:messages].empty?
|
|
110
|
-
|
|
109
|
+
p(class: styles.empty) { "No messages yet." }
|
|
111
110
|
else
|
|
112
111
|
state[:messages].each do |message|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
+
form(onsubmit: :send_message, class: styles.form) do
|
|
122
121
|
if state[:error]
|
|
123
|
-
|
|
122
|
+
div(class: styles.error) { state[:error] }
|
|
124
123
|
end
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
data/lib/tasks/funicular.rake
CHANGED
|
@@ -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
|
-
|
|
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
|