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
|
@@ -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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The tag whitelist lives in mrblib/0_tags.rb (runtime) and is manually
|
|
6
|
+
# enumerated in sig/tags.rbs and sig/view_context.rbs (types). These drifted
|
|
7
|
+
# in the past; fail loudly when they do again.
|
|
8
|
+
class SigTagsTest < Minitest::Test
|
|
9
|
+
ROOT = File.expand_path("..", __dir__)
|
|
10
|
+
|
|
11
|
+
def setup
|
|
12
|
+
Funicular::SSR::Runtime.load_framework!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assert_sig_covers_all_tags(sig_path)
|
|
16
|
+
sig = File.read(File.join(ROOT, sig_path))
|
|
17
|
+
missing = Funicular::Tags::HTML_TAGS.reject do |tag|
|
|
18
|
+
sig.include?("def #{tag}: ")
|
|
19
|
+
end
|
|
20
|
+
assert_empty missing, "#{sig_path} is missing tag signatures: #{missing.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_sig_tags_rbs_covers_all_tags
|
|
24
|
+
assert_sig_covers_all_tags("sig/tags.rbs")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_sig_view_context_rbs_covers_all_tags
|
|
28
|
+
assert_sig_covers_all_tags("sig/view_context.rbs")
|
|
29
|
+
end
|
|
30
|
+
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))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
require "test_helper"
|
|
7
|
+
|
|
8
|
+
# Covers the freshness decision behind Funicular::Testing.ensure_compiled!.
|
|
9
|
+
# The actual compile (Node + vendored mrbc) is integration-only, matching
|
|
10
|
+
# the compiler tests' scope.
|
|
11
|
+
class TestingEnsureCompiledTest < Minitest::Test
|
|
12
|
+
def test_stale_when_bundle_is_missing
|
|
13
|
+
Dir.mktmpdir do |dir|
|
|
14
|
+
assert Funicular::Testing.stale?(File.join(dir, "app.mrb"), [])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_fresh_when_bundle_is_newer_than_every_source
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
source = File.join(dir, "component.rb")
|
|
21
|
+
output = File.join(dir, "app.mrb")
|
|
22
|
+
File.write(source, "")
|
|
23
|
+
File.write(output, "")
|
|
24
|
+
File.utime(Time.now - 60, Time.now - 60, source)
|
|
25
|
+
|
|
26
|
+
refute Funicular::Testing.stale?(output, [ source ])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_stale_when_any_source_is_newer_than_the_bundle
|
|
31
|
+
Dir.mktmpdir do |dir|
|
|
32
|
+
old_source = File.join(dir, "old.rb")
|
|
33
|
+
new_source = File.join(dir, "new.rb")
|
|
34
|
+
output = File.join(dir, "app.mrb")
|
|
35
|
+
[ old_source, new_source, output ].each { |f| File.write(f, "") }
|
|
36
|
+
File.utime(Time.now - 60, Time.now - 60, old_source)
|
|
37
|
+
File.utime(Time.now - 30, Time.now - 30, output)
|
|
38
|
+
File.utime(Time.now, Time.now, new_source)
|
|
39
|
+
|
|
40
|
+
assert Funicular::Testing.stale?(output, [ old_source, new_source ])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_missing_sources_do_not_count
|
|
45
|
+
Dir.mktmpdir do |dir|
|
|
46
|
+
output = File.join(dir, "app.mrb")
|
|
47
|
+
File.write(output, "")
|
|
48
|
+
|
|
49
|
+
refute Funicular::Testing.stale?(output, [ File.join(dir, "gone.rb") ])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -30,6 +30,35 @@ class ValidationsTest < Minitest::Test
|
|
|
30
30
|
assert_equal true, k.new("name" => "Alice").valid?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def test_uncompilable_format_regex_is_skipped_not_fatal
|
|
34
|
+
k = model_class
|
|
35
|
+
out, _err = capture_io do
|
|
36
|
+
k.register_schema_validations(
|
|
37
|
+
"name" => {
|
|
38
|
+
"format" => { "with" => "[", "flags" => "" },
|
|
39
|
+
"presence" => true
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
# The warning names the rejected pattern so it can be found among
|
|
44
|
+
# many schemas loading at boot.
|
|
45
|
+
assert_match(/Skipping format validator/, out)
|
|
46
|
+
assert_match(/pattern: \[/, out)
|
|
47
|
+
# The broken format validator is dropped; the rest still applies and
|
|
48
|
+
# schema registration does not raise.
|
|
49
|
+
blank = k.new("name" => "")
|
|
50
|
+
assert_equal false, blank.valid?
|
|
51
|
+
assert_equal true, k.new("name" => "Alice").valid?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_schema_numericality_with_allow_nil_skips_nil
|
|
55
|
+
k = model_class({ "age" => false })
|
|
56
|
+
k.register_schema_validations("age" => { "numericality" => { "allow_nil" => true } })
|
|
57
|
+
|
|
58
|
+
assert_equal true, k.new("age" => nil).valid?
|
|
59
|
+
assert_equal false, k.new("age" => "abc").valid?
|
|
60
|
+
end
|
|
61
|
+
|
|
33
62
|
def test_length_maximum_and_minimum
|
|
34
63
|
k = model_class
|
|
35
64
|
k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
|
|
@@ -170,14 +199,15 @@ class ValidationsTest < Minitest::Test
|
|
|
170
199
|
m = k.new("name" => "ok")
|
|
171
200
|
m.instance_variable_set("@id", 1)
|
|
172
201
|
|
|
173
|
-
got_success = :unset
|
|
174
202
|
got_result = :unset
|
|
175
|
-
|
|
176
|
-
|
|
203
|
+
got_error = :unset
|
|
204
|
+
m.update("name" => "") do |result, error|
|
|
177
205
|
got_result = result
|
|
206
|
+
got_error = error
|
|
178
207
|
end
|
|
179
208
|
|
|
180
|
-
|
|
181
|
-
|
|
209
|
+
# Unified 0.5.0 callback contract: (result, error), result nil on failure
|
|
210
|
+
assert_nil got_result
|
|
211
|
+
assert_equal ["can't be blank"], got_error[:name]
|
|
182
212
|
end
|
|
183
213
|
end
|
|
@@ -7,7 +7,7 @@ class ViewContextTest < Minitest::Test
|
|
|
7
7
|
Funicular::SSR::Runtime.load_framework!
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def test_state_styles_and_resources_are_reachable_alongside_tags
|
|
11
11
|
klass = Class.new(Funicular::Component) do
|
|
12
12
|
styles { |css| css.define :hash, "hash-class" }
|
|
13
13
|
|
|
@@ -17,9 +17,9 @@ class ViewContextTest < Minitest::Test
|
|
|
17
17
|
{ class: "state-class", hash: "state-hash" }
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def render
|
|
21
|
-
|
|
22
|
-
"#{state[:class]} #{state[:hash]} #{
|
|
20
|
+
def render
|
|
21
|
+
data(class: styles[:hash]) do
|
|
22
|
+
"#{state[:class]} #{state[:hash]} #{resources[:data]}"
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -37,15 +37,15 @@ class ViewContextTest < Minitest::Test
|
|
|
37
37
|
|
|
38
38
|
def test_component_children_are_vdom_children_not_props
|
|
39
39
|
child = Class.new(Funicular::Component) do
|
|
40
|
-
def render
|
|
41
|
-
|
|
40
|
+
def render
|
|
41
|
+
div { children }
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
parent = Class.new(Funicular::Component) do
|
|
46
|
-
define_method(:render) do
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
define_method(:render) do
|
|
47
|
+
component(child, title: "x") do
|
|
48
|
+
span { "inside" }
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
@@ -70,23 +70,23 @@ class ViewContextTest < Minitest::Test
|
|
|
70
70
|
component_a.runtime = Funicular::Runtime.new(router_a)
|
|
71
71
|
component_b.runtime = Funicular::Runtime.new(router_b)
|
|
72
72
|
|
|
73
|
-
assert_equal "/users/7",
|
|
74
|
-
assert_equal "/accounts/7",
|
|
73
|
+
assert_equal "/users/7", component_a.routes.user_path(7)
|
|
74
|
+
assert_equal "/accounts/7", component_b.routes.user_path(7)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def test_child_collector_is_restored_when_render_raises
|
|
78
78
|
klass = Class.new(Funicular::Component) do
|
|
79
79
|
attr_reader :captured
|
|
80
80
|
|
|
81
|
-
def render
|
|
81
|
+
def render
|
|
82
82
|
begin
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
div do
|
|
84
|
+
span { "before" }
|
|
85
85
|
raise "boom"
|
|
86
86
|
end
|
|
87
87
|
rescue
|
|
88
88
|
@captured = current_children
|
|
89
|
-
|
|
89
|
+
div { "after" }
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
end
|
data/mrbgem.rake
CHANGED
|
@@ -7,7 +7,9 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
|
7
7
|
spec.add_dependency 'picoruby-wasm'
|
|
8
8
|
end
|
|
9
9
|
spec.add_dependency 'picoruby-indexeddb'
|
|
10
|
+
spec.add_dependency 'picoruby-sqlite3'
|
|
10
11
|
spec.add_dependency 'picoruby-json'
|
|
12
|
+
spec.add_dependency 'picoruby-uri'
|
|
11
13
|
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
12
14
|
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|
|
13
15
|
spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
|
data/mrblib/0_tags.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
# Raised at class-definition time (method_added) or first mount when a
|
|
3
|
+
# component defines a method whose name collides with the view DSL.
|
|
4
|
+
class DSLCollisionError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a tag helper is called while the component is not rendering
|
|
7
|
+
# (e.g. from a stale proc or an event handler outside build_vdom).
|
|
8
|
+
class RenderContextError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Bareword tag DSL mixed into Funicular::Component. Tags are real public
|
|
11
|
+
# instance methods so `div(...)` works with self = the component inside
|
|
12
|
+
# `render`. Element construction is delegated to the component's internal
|
|
13
|
+
# ViewContext, which remains the single element factory (FormBuilder and
|
|
14
|
+
# the framework internals keep using it directly).
|
|
15
|
+
module Tags
|
|
16
|
+
# Single source of truth for the tag whitelist. sig/tags.rbs must list
|
|
17
|
+
# one method per entry; test/sig_tags_test.rb enforces that in CI.
|
|
18
|
+
HTML_TAGS = %w[
|
|
19
|
+
div span p a data
|
|
20
|
+
h1 h2 h3 h4 h5 h6
|
|
21
|
+
ul ol li
|
|
22
|
+
table thead tbody tr th td
|
|
23
|
+
form input textarea button select option label
|
|
24
|
+
header footer nav section article aside
|
|
25
|
+
img video audio canvas
|
|
26
|
+
br hr
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
HELPER_NAMES = %w[
|
|
30
|
+
tag component form_for link_to button_to suspense
|
|
31
|
+
state props styles resources routes patch
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Symbol => :tag | :helper. Component.method_added consults this to turn
|
|
35
|
+
# silent shadowing into a load-time error.
|
|
36
|
+
RESERVED_DSL = {} #: Hash[Symbol, Symbol]
|
|
37
|
+
HTML_TAGS.each { |name| RESERVED_DSL[name.to_sym] = :tag }
|
|
38
|
+
HELPER_NAMES.each { |name| RESERVED_DSL[name.to_sym] = :helper }
|
|
39
|
+
|
|
40
|
+
HTML_TAGS.each do |tag_name|
|
|
41
|
+
define_method(tag_name) do |props = {}, &block|
|
|
42
|
+
# @type self: Funicular::Component
|
|
43
|
+
unless props.is_a?(Hash)
|
|
44
|
+
hint = if tag_name == "p"
|
|
45
|
+
" Inside a component, `p` builds a <p> element; use `puts x.inspect` to debug."
|
|
46
|
+
else
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
raise ArgumentError, "#{tag_name}() expects an attributes Hash, got #{props.class}.#{hint}"
|
|
50
|
+
end
|
|
51
|
+
__view__.tag(tag_name, props, &block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Escape hatch: emit any element, including custom elements outside the
|
|
56
|
+
# whitelist or a tag whose bareword is shadowed via allow_dsl_override.
|
|
57
|
+
def tag(name, props = {}, &block)
|
|
58
|
+
# @type self: Funicular::Component
|
|
59
|
+
__view__.tag(name, props, &block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/mrblib/cable.rb
CHANGED
|
@@ -229,7 +229,7 @@ module Funicular
|
|
|
229
229
|
|
|
230
230
|
# Setup beforeunload handler to persist pending commands
|
|
231
231
|
def setup_beforeunload_handler
|
|
232
|
-
@beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
|
|
232
|
+
@beforeunload_callback_id = JS.global.addEventListener("beforeunload", sync: true) do
|
|
233
233
|
save_pending_to_storage
|
|
234
234
|
end
|
|
235
235
|
end
|