funicular 0.2.1 → 0.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +2 -2
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +17 -17
  6. data/demo/test_component.html +15 -15
  7. data/demo/test_error_boundary.html +41 -41
  8. data/demo/test_router.html +57 -57
  9. data/demo/tic-tac-toe.html +29 -29
  10. data/docs/architecture.md +43 -30
  11. data/lib/funicular/compiler.rb +13 -13
  12. data/lib/funicular/helpers/picoruby_helper.rb +24 -1
  13. data/lib/funicular/ssr/runtime.rb +2 -0
  14. data/lib/funicular/ssr.rb +2 -1
  15. data/lib/funicular/testing/node_runner.rb +1 -1
  16. data/lib/funicular/vendor/mrbc/VERSION +1 -0
  17. data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +14 -1
  18. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  19. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  20. data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
  21. data/lib/funicular/vendor/picoruby/debug/picoruby.js +16 -10
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  23. data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +85 -84
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  30. data/lib/funicular/version.rb +1 -1
  31. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +47 -46
  32. data/lib/tasks/funicular.rake +1 -1
  33. data/minitest/commands_routes_test.rb +97 -0
  34. data/minitest/compiler_test.rb +195 -0
  35. data/minitest/configuration_test.rb +64 -0
  36. data/minitest/fixtures/funicular_app/components/greeting_component.rb +6 -6
  37. data/minitest/form_for_test.rb +2 -2
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/hydration_test.rb +2 -2
  40. data/minitest/middleware_test.rb +154 -0
  41. data/minitest/picoruby_helper_test.rb +139 -0
  42. data/minitest/route_parser_test.rb +139 -0
  43. data/minitest/schema_test.rb +23 -0
  44. data/minitest/ssr_test.rb +57 -0
  45. data/minitest/support/rails_stub.rb +59 -0
  46. data/minitest/test_helper.rb +20 -0
  47. data/minitest/testing_test.rb +267 -0
  48. data/minitest/view_context_test.rb +101 -0
  49. data/mrblib/component.rb +158 -237
  50. data/mrblib/debug.rb +7 -6
  51. data/mrblib/differ.rb +3 -1
  52. data/mrblib/error_boundary.rb +19 -27
  53. data/mrblib/form_builder.rb +28 -24
  54. data/mrblib/funicular.rb +2 -1
  55. data/mrblib/html_serializer.rb +14 -13
  56. data/mrblib/http.rb +12 -1
  57. data/mrblib/patcher.rb +12 -9
  58. data/mrblib/router.rb +9 -5
  59. data/mrblib/runtime.rb +28 -0
  60. data/mrblib/styles.rb +13 -17
  61. data/mrblib/vdom.rb +90 -9
  62. data/mrblib/view_context.rb +135 -0
  63. data/sig/component.rbs +34 -85
  64. data/sig/error_boundary.rbs +4 -4
  65. data/sig/form_builder.rbs +2 -1
  66. data/sig/html_serializer.rbs +2 -1
  67. data/sig/http.rbs +1 -0
  68. data/sig/patcher.rbs +1 -1
  69. data/sig/router.rbs +2 -13
  70. data/sig/runtime.rbs +13 -0
  71. data/sig/styles.rbs +3 -4
  72. data/sig/vdom.rbs +11 -2
  73. data/sig/view_context.rbs +47 -0
  74. metadata +18 -5
  75. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  76. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+ require_relative "support/rails_stub"
8
+ require "funicular/middleware"
9
+
10
+ # Exercises the development-only recompile middleware: when it decides to
11
+ # recompile (env + source dir presence + mtime), the compiling/last_mtime
12
+ # guard state machine, and that a compiler failure is swallowed so the request
13
+ # still passes through. The real mrbc spawn is stubbed out.
14
+ class MiddlewareTest < Minitest::Test
15
+ def setup
16
+ Rails.reset_stub!
17
+ Rails.env_name = "development"
18
+ Funicular::Middleware.reset!
19
+ @downstream_calls = 0
20
+ @app = ->(env) { @downstream_calls += 1; [200, {}, ["ok"]] }
21
+ end
22
+
23
+ def teardown
24
+ Rails.reset_stub!
25
+ Funicular::Middleware.reset!
26
+ end
27
+
28
+ # Builds an app dir with one source file and points Rails.root at it.
29
+ def with_app
30
+ Dir.mktmpdir do |dir|
31
+ FileUtils.mkdir_p(File.join(dir, "app", "funicular"))
32
+ File.write(File.join(dir, "app", "funicular", "home_component.rb"), "# c\n")
33
+ Rails.root = Pathname(dir)
34
+ yield dir
35
+ end
36
+ end
37
+
38
+ # A compiler double whose #compile just records that it ran.
39
+ def recording_compiler
40
+ compiler = Object.new
41
+ compiler.define_singleton_method(:compiled?) { @compiled ||= false }
42
+ compiler.define_singleton_method(:compile) { @compiled = true }
43
+ compiler
44
+ end
45
+
46
+ # Minitest 6 dropped Object#stub, so swap Compiler.new by hand. `replacement`
47
+ # is returned as the compiler, unless it's callable, in which case it's
48
+ # invoked with the constructor args (used to assert "never compiled").
49
+ def stub_compiler_new(replacement)
50
+ original = Funicular::Compiler.method(:new)
51
+ Funicular::Compiler.define_singleton_method(:new) do |*args, **kwargs|
52
+ replacement.respond_to?(:call) ? replacement.call(*args, **kwargs) : replacement
53
+ end
54
+ yield
55
+ ensure
56
+ Funicular::Compiler.singleton_class.send(:remove_method, :new)
57
+ Funicular::Compiler.define_singleton_method(:new, original)
58
+ end
59
+
60
+ def test_passes_request_through_downstream
61
+ with_app do
62
+ stub_compiler_new(recording_compiler) do
63
+ status, _headers, body = Funicular::Middleware.new(@app).call({})
64
+ assert_equal 200, status
65
+ assert_equal ["ok"], body
66
+ end
67
+ end
68
+ assert_equal 1, @downstream_calls
69
+ end
70
+
71
+ def test_skips_recompile_outside_development
72
+ Rails.env_name = "production"
73
+ with_app do
74
+ stub_compiler_new(->(*) { flunk "should not compile in production" }) do
75
+ Funicular::Middleware.new(@app).call({})
76
+ end
77
+ end
78
+ assert_nil Funicular::Middleware.last_mtime
79
+ end
80
+
81
+ def test_skips_recompile_when_source_dir_absent
82
+ Dir.mktmpdir do |dir|
83
+ Rails.root = Pathname(dir) # no app/funicular
84
+ stub_compiler_new(->(*) { flunk "should not compile without sources" }) do
85
+ Funicular::Middleware.new(@app).call({})
86
+ end
87
+ assert_nil Funicular::Middleware.last_mtime
88
+ end
89
+ end
90
+
91
+ def test_compiles_and_records_last_mtime_on_first_request
92
+ with_app do
93
+ compiler = recording_compiler
94
+ stub_compiler_new(compiler) do
95
+ Funicular::Middleware.new(@app).call({})
96
+ end
97
+ assert compiler.compiled?, "expected the compiler to run"
98
+ refute_nil Funicular::Middleware.last_mtime
99
+ refute Funicular::Middleware.compiling, "compiling flag must be cleared"
100
+ end
101
+ end
102
+
103
+ def test_skips_recompile_when_sources_unchanged
104
+ with_app do
105
+ # Pretend a compile already happened in the future so nothing is stale.
106
+ Funicular::Middleware.last_mtime = Time.now + 3600
107
+ stub_compiler_new(->(*) { flunk "should not recompile unchanged sources" }) do
108
+ Funicular::Middleware.new(@app).call({})
109
+ end
110
+ end
111
+ end
112
+
113
+ def test_compiler_failure_is_swallowed_and_request_continues
114
+ with_app do
115
+ exploding = Object.new
116
+ exploding.define_singleton_method(:compile) { raise "mrbc boom" }
117
+ stub_compiler_new(exploding) do
118
+ status, = Funicular::Middleware.new(@app).call({})
119
+ assert_equal 200, status
120
+ end
121
+ # Failure path must still clear the compiling guard.
122
+ refute Funicular::Middleware.compiling
123
+ end
124
+ assert_equal 1, @downstream_calls
125
+ end
126
+
127
+ def test_successful_compile_sweeps_the_asset_pipeline_cache
128
+ swept = []
129
+ sweeper = Object.new
130
+ sweeper.define_singleton_method(:execute) { swept << true }
131
+ # Struct doubles mirror the Propshaft chain the middleware walks; their
132
+ # respond_to? answers true for each member, matching the real objects.
133
+ load_path = Struct.new(:cache_sweeper).new(sweeper)
134
+ assets = Struct.new(:load_path).new(load_path)
135
+ Rails.application = Struct.new(:assets).new(assets)
136
+
137
+ with_app do
138
+ stub_compiler_new(recording_compiler) do
139
+ Funicular::Middleware.new(@app).call({})
140
+ end
141
+ end
142
+
143
+ assert_equal [true], swept, "expected the Propshaft cache sweeper to run"
144
+ end
145
+
146
+ def test_reset_clears_class_state
147
+ Funicular::Middleware.last_mtime = Time.now
148
+ Funicular::Middleware.compiling = true
149
+ Funicular::Middleware.reset!
150
+ assert_nil Funicular::Middleware.last_mtime
151
+ refute Funicular::Middleware.compiling
152
+ assert_instance_of Mutex, Funicular::Middleware.mutex
153
+ end
154
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ require "test_helper"
6
+ require_relative "support/rails_stub"
7
+ require "action_view"
8
+ require "funicular/helpers/picoruby_helper"
9
+
10
+ # Exercises the ActionView helpers (Funicular::Helpers::PicorubyHelper): the
11
+ # <script> bootstrap tag across the local/cdn sources, the SSR container, and
12
+ # the security-critical state-tag escaping. A minimal ActionView harness plus
13
+ # the Rails stub stand in for a booted app.
14
+ class PicorubyHelperTest < Minitest::Test
15
+ # Just enough ActionView plumbing for the helper's tag/raw/safe_join calls.
16
+ class Harness
17
+ include ActionView::Helpers::TagHelper
18
+ include ActionView::Helpers::OutputSafetyHelper
19
+ include Funicular::Helpers::PicorubyHelper
20
+ end
21
+
22
+ def setup
23
+ Rails.reset_stub!
24
+ @view = Harness.new
25
+ @config = Funicular::Configuration.new
26
+ Funicular.instance_variable_set(:@configuration, @config)
27
+ end
28
+
29
+ def teardown
30
+ Rails.reset_stub!
31
+ Funicular.instance_variable_set(:@configuration, nil)
32
+ end
33
+
34
+ # --- picoruby_include_tag --------------------------------------------
35
+
36
+ def test_local_dist_source_emits_script_and_base_style
37
+ html = @view.picoruby_include_tag(source: :local_dist)
38
+ assert_includes html, '<script src="/picoruby/dist/init.iife.js?v='
39
+ assert_includes html, "<style"
40
+ assert_includes html, "data-funicular-base"
41
+ end
42
+
43
+ def test_local_debug_source_path
44
+ html = @view.picoruby_include_tag(source: :local_debug)
45
+ assert_includes html, '<script src="/picoruby/debug/init.iife.js?v='
46
+ end
47
+
48
+ def test_base_styles_can_be_skipped
49
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
50
+ assert_includes html, "<script"
51
+ refute_includes html, "<style"
52
+ end
53
+
54
+ def test_source_defaults_to_configuration_for_current_env
55
+ Rails.env_name = "production"
56
+ @config.production_source = :local_dist
57
+ html = @view.picoruby_include_tag
58
+ assert_includes html, "/picoruby/dist/init.iife.js"
59
+ end
60
+
61
+ def test_extra_options_become_script_attributes
62
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false, defer: true)
63
+ assert_includes html, "defer"
64
+ end
65
+
66
+ def test_cdn_source_uses_versioned_jsdelivr_url
67
+ @config.cdn_version = "1.2.3"
68
+ html = @view.picoruby_include_tag(source: :cdn, base_styles: false)
69
+ assert_includes html,
70
+ "https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@1.2.3/dist/init.iife.js"
71
+ end
72
+
73
+ def test_cdn_source_without_version_raises
74
+ @config.define_singleton_method(:cdn_version) { nil }
75
+ error = assert_raises(ArgumentError) do
76
+ @view.picoruby_include_tag(source: :cdn)
77
+ end
78
+ assert_includes error.message, ":cdn requires a version"
79
+ end
80
+
81
+ def test_unknown_source_raises
82
+ error = assert_raises(ArgumentError) do
83
+ @view.picoruby_include_tag(source: :bogus)
84
+ end
85
+ assert_includes error.message, "Unknown picoruby source"
86
+ end
87
+
88
+ # --- funicular_app_container -----------------------------------------
89
+
90
+ def test_app_container_wraps_html_with_default_id
91
+ html = @view.funicular_app_container("<h1>Hi</h1>")
92
+ assert_includes html, '<div id="app">'
93
+ assert_includes html, "<h1>Hi</h1>" # raw, not escaped
94
+ end
95
+
96
+ def test_app_container_accepts_custom_id_and_attributes
97
+ html = @view.funicular_app_container("", id: "root", class: "shell")
98
+ assert_includes html, 'id="root"'
99
+ assert_includes html, 'class="shell"'
100
+ end
101
+
102
+ # --- funicular_state_tag (XSS-sensitive) -----------------------------
103
+
104
+ def test_state_tag_serializes_state_as_json
105
+ html = @view.funicular_state_tag({ "title" => "Channels" })
106
+ assert_includes html, "window.__FUNICULAR_STATE__ = "
107
+ assert_includes html, '"title":"Channels"'
108
+ end
109
+
110
+ def test_state_tag_escapes_script_breaking_characters
111
+ html = @view.funicular_state_tag({ "x" => "</script><b>&" })
112
+ refute_includes html, "</script><b>"
113
+ assert_includes html, "\\u003c"
114
+ assert_includes html, "\\u003e"
115
+ assert_includes html, "\\u0026"
116
+ end
117
+
118
+ def test_state_tag_defaults_to_empty_object
119
+ assert_includes @view.funicular_state_tag, "window.__FUNICULAR_STATE__ = {};"
120
+ assert_includes @view.funicular_state_tag(nil), "{};"
121
+ end
122
+
123
+ # --- funicular_plugin_include_tags -----------------------------------
124
+
125
+ def test_plugin_include_tags_empty_when_no_plugins
126
+ Dir.mktmpdir do |dir|
127
+ Rails.root = Pathname(dir)
128
+ assert_equal "", @view.funicular_plugin_include_tags
129
+ end
130
+ end
131
+
132
+ # --- base_css --------------------------------------------------------
133
+
134
+ def test_base_css_is_read_once
135
+ css = Funicular::Helpers::PicorubyHelper.base_css
136
+ assert_kind_of String, css
137
+ assert_same css, Funicular::Helpers::PicorubyHelper.base_css
138
+ end
139
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require "test_helper"
6
+ require "funicular/route_parser"
7
+
8
+ # Exercises Funicular::RouteParser, the line-based scanner that extracts route
9
+ # definitions from an app's app/funicular/initializer.rb (used by the
10
+ # `funicular:routes` command). It is deliberately regex-free string slicing, so
11
+ # the tests cover the quoting, `to:`/`as:` and legacy `add_route` shapes.
12
+ class RouteParserTest < Minitest::Test
13
+ def parse(source)
14
+ Tempfile.create(["initializer", ".rb"]) do |f|
15
+ f.write(source)
16
+ f.flush
17
+ Funicular::RouteParser.new(f.path).parse
18
+ end
19
+ end
20
+
21
+ def test_missing_file_returns_empty
22
+ assert_equal [], Funicular::RouteParser.new("/no/such/file.rb").parse
23
+ end
24
+
25
+ def test_parses_get_with_to_and_as
26
+ routes = parse(<<~RUBY)
27
+ router.get("/channels", to: ChannelsComponent, as: "channels")
28
+ RUBY
29
+
30
+ assert_equal 1, routes.size
31
+ assert_equal(
32
+ { method: "GET", path: "/channels", component: "ChannelsComponent", helper: "channels_path" },
33
+ routes.first
34
+ )
35
+ end
36
+
37
+ def test_upcases_each_http_method
38
+ routes = parse(<<~RUBY)
39
+ router.get("/a", to: A)
40
+ router.post("/b", to: B)
41
+ router.put("/c", to: C)
42
+ router.patch("/d", to: D)
43
+ router.delete("/e", to: E)
44
+ RUBY
45
+
46
+ assert_equal %w[GET POST PUT PATCH DELETE], routes.map { |r| r[:method] }
47
+ end
48
+
49
+ def test_legacy_add_route_uses_second_argument_and_get_method
50
+ routes = parse(<<~RUBY)
51
+ router.add_route('/legacy', LegacyComponent)
52
+ RUBY
53
+
54
+ assert_equal(
55
+ { method: "GET", path: "/legacy", component: "LegacyComponent", helper: nil },
56
+ routes.first
57
+ )
58
+ end
59
+
60
+ def test_helper_is_nil_without_as_option
61
+ routes = parse(<<~RUBY)
62
+ router.get("/plain", to: PlainComponent)
63
+ RUBY
64
+
65
+ assert_nil routes.first[:helper]
66
+ end
67
+
68
+ def test_single_and_double_quotes_are_both_accepted
69
+ routes = parse(<<~RUBY)
70
+ router.get('/single', to: Single)
71
+ router.get("/double", to: Double)
72
+ RUBY
73
+
74
+ assert_equal ["/single", "/double"], routes.map { |r| r[:path] }
75
+ end
76
+
77
+ def test_comments_and_blank_lines_are_skipped
78
+ routes = parse(<<~RUBY)
79
+ # router.get("/commented", to: Commented)
80
+
81
+ # indented comment
82
+ router.get("/real", to: Real)
83
+ RUBY
84
+
85
+ assert_equal ["/real"], routes.map { |r| r[:path] }
86
+ end
87
+
88
+ def test_non_router_lines_are_ignored
89
+ routes = parse(<<~RUBY)
90
+ Funicular.configure do |config|
91
+ config.something = true
92
+ end
93
+ router.get("/only", to: Only)
94
+ RUBY
95
+
96
+ assert_equal 1, routes.size
97
+ assert_equal "/only", routes.first[:path]
98
+ end
99
+
100
+ def test_line_without_path_is_skipped
101
+ routes = parse(<<~RUBY)
102
+ router.get(to: NoPath)
103
+ RUBY
104
+
105
+ assert_equal [], routes
106
+ end
107
+
108
+ def test_line_without_component_is_skipped
109
+ routes = parse(<<~RUBY)
110
+ router.get("/orphan")
111
+ RUBY
112
+
113
+ assert_equal [], routes
114
+ end
115
+
116
+ def test_component_before_trailing_options_is_isolated
117
+ routes = parse(<<~RUBY)
118
+ router.get("/x", to: XComponent, as: "x", extra: :ignored)
119
+ RUBY
120
+
121
+ assert_equal "XComponent", routes.first[:component]
122
+ assert_equal "x_path", routes.first[:helper]
123
+ end
124
+
125
+ def test_component_at_end_of_line_without_trailing_delimiter
126
+ routes = parse("router.get \"/y\", to: YComponent")
127
+
128
+ assert_equal "YComponent", routes.first[:component]
129
+ end
130
+
131
+ def test_as_option_without_a_quoted_value_yields_nil_helper
132
+ routes = parse(<<~RUBY)
133
+ router.get("/z", to: ZComponent, as: some_variable)
134
+ RUBY
135
+
136
+ assert_equal "ZComponent", routes.first[:component]
137
+ assert_nil routes.first[:helper]
138
+ end
139
+ end
@@ -88,6 +88,29 @@ class SchemaDerivationTest < Minitest::Test
88
88
  assert_equal({ "update" => { method: "PATCH", path: "/x/:id" } }, schema[:endpoints])
89
89
  end
90
90
 
91
+ def test_length_range_is_serialized_as_min_and_max
92
+ klass = Class.new do
93
+ include ActiveModel::Validations
94
+ def self.name = "Ranged"
95
+ attr_accessor :bio
96
+ validates :bio, length: { in: 2..10 }
97
+ end
98
+ result = Funicular::Schema.validations_for(klass, ["bio"])
99
+ assert_equal({ "minimum" => 2, "maximum" => 10 }, result["bio"]["length"])
100
+ end
101
+
102
+ def test_format_multiline_flag_is_translated
103
+ klass = Class.new do
104
+ include ActiveModel::Validations
105
+ def self.name = "Multi"
106
+ attr_accessor :body
107
+ validates :body, format: { with: /\Aline/m }
108
+ end
109
+ result = Funicular::Schema.validations_for(klass, ["body"])
110
+ assert_equal "m", result["body"]["format"]["flags"]
111
+ assert_equal "^line", result["body"]["format"]["with"]
112
+ end
113
+
91
114
  def test_extended_regexp_is_skipped
92
115
  klass = Class.new do
93
116
  include ActiveModel::Validations
data/minitest/ssr_test.rb CHANGED
@@ -59,6 +59,36 @@ class SSRTest < Minitest::Test
59
59
  serialize(el("a", { href: "javascript:alert(1)" }, ["x"]))
60
60
  end
61
61
 
62
+ def test_security_checks_are_case_insensitive
63
+ assert_equal "<button>Go</button>",
64
+ serialize(el("button", { "ONCLICK" => "alert(1)" }, ["Go"]))
65
+ assert_equal "<a>x</a>",
66
+ serialize(el("a", { "HREF" => "JAVASCRIPT:alert(1)" }, ["x"]))
67
+ end
68
+
69
+ def test_blocks_obfuscated_javascript_uri
70
+ assert_equal "<a>x</a>",
71
+ serialize(el("a", { href: "java\nscript:alert(1)" }, ["x"]))
72
+ assert_equal "<a>x</a>",
73
+ serialize(el("a", { href: "\x01javascript:alert(1)" }, ["x"]))
74
+ end
75
+
76
+ def test_blocks_srcdoc
77
+ assert_equal "<div></div>",
78
+ serialize(el("div", { srcdoc: "<script>alert(1)</script>" }))
79
+ end
80
+
81
+ def test_rejects_invalid_attribute_name
82
+ assert_raises(ArgumentError) do
83
+ el("div", { 'x" autofocus onfocus' => "alert(1)" })
84
+ end
85
+ end
86
+
87
+ def test_rejects_invalid_and_active_tag_names
88
+ assert_raises(ArgumentError) { el('div><script>alert(1)</script><div') }
89
+ assert_raises(ArgumentError) { el("script", {}, ["alert(1)"]) }
90
+ end
91
+
62
92
  # --- Full SSR render (runtime + fixture app) --------------------------
63
93
 
64
94
  def test_render_injects_server_state
@@ -91,4 +121,31 @@ class SSRTest < Minitest::Test
91
121
  result = Funicular::SSR.render(path: "/greet/42", source_dir: APP_DIR)
92
122
  assert_includes result[:html], "greeting"
93
123
  end
124
+
125
+ def test_symbolize_keys_handles_nil_and_string_keys
126
+ assert_equal({}, Funicular::SSR.symbolize_keys(nil))
127
+ assert_equal({ a: 1, b: 2 }, Funicular::SSR.symbolize_keys("a" => 1, "b" => 2))
128
+ end
129
+
130
+ # --- Runtime lifecycle flags -----------------------------------------
131
+
132
+ def test_framework_reports_loaded_after_setup
133
+ assert Funicular::SSR::Runtime.framework_loaded?
134
+ end
135
+
136
+ def test_reset_app_allows_rebooting_a_different_app
137
+ Funicular::SSR::Runtime.boot!(APP_DIR)
138
+ Funicular::SSR::Runtime.reset_app!
139
+ # A second boot after reset re-`load`s the fixture, which redefines its
140
+ # methods; that "method redefined" warning is expected here (it's the whole
141
+ # point of reset_app!), so silence it rather than leak noise into the run.
142
+ original_verbose = $VERBOSE
143
+ $VERBOSE = nil
144
+ begin
145
+ Funicular::SSR::Runtime.boot!(APP_DIR)
146
+ ensure
147
+ $VERBOSE = original_verbose
148
+ end
149
+ assert Funicular::SSR::Runtime.framework_loaded?
150
+ end
94
151
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "pathname"
5
+
6
+ # A tiny stand-in for the slice of Rails the funicular gem touches, so the
7
+ # Rails-integration units (middleware, view helpers, the CLI command) can be
8
+ # exercised under plain minitest without booting a real application.
9
+ #
10
+ # Only what the gem actually reads is implemented: Rails.env (with the
11
+ # `env.development?` style predicates), Rails.root, Rails.logger, and
12
+ # Rails.application. Everything is resettable so tests stay isolated.
13
+ module Rails
14
+ # String subclass answering `development?` / `production?` / `test?` etc.,
15
+ # mirroring ActiveSupport::StringInquirer closely enough for the gem's needs.
16
+ class EnvInquirer < String
17
+ def method_missing(name, *args)
18
+ str = name.to_s
19
+ return super unless str.end_with?("?")
20
+
21
+ self == str.chomp("?")
22
+ end
23
+
24
+ def respond_to_missing?(name, _include_private = false)
25
+ name.to_s.end_with?("?") || super
26
+ end
27
+ end
28
+
29
+ class << self
30
+ attr_writer :root, :logger, :application
31
+ attr_accessor :env_name
32
+
33
+ def env
34
+ EnvInquirer.new(env_name || "test")
35
+ end
36
+
37
+ def root
38
+ @root
39
+ end
40
+
41
+ def logger
42
+ @logger ||= Logger.new(IO::NULL)
43
+ end
44
+
45
+ def application
46
+ @application
47
+ end
48
+
49
+ # Restore defaults between tests.
50
+ def reset_stub!
51
+ @root = nil
52
+ @logger = Logger.new(IO::NULL)
53
+ @application = nil
54
+ @env_name = "test"
55
+ end
56
+ end
57
+
58
+ reset_stub!
59
+ end
@@ -1,5 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Coverage must start before any lib/ file is required so every line is
4
+ # tracked. Opt in via `rake test:coverage` (or COVERAGE=1) to avoid slowing
5
+ # down focused, single-file runs.
6
+ if ENV["COVERAGE"]
7
+ require "simplecov"
8
+ SimpleCov.start do
9
+ # Only the gem's own Ruby implementation (lib/) is in scope here. The
10
+ # PicoRuby runtime (mrblib/), the client test suite (test/), and the
11
+ # minitest suite itself are out of scope for this coverage report.
12
+ root File.expand_path("..", __dir__)
13
+ add_filter %r{\A/(minitest|test|mrblib|sig|exe|bin)/}
14
+ track_files "lib/**/*.rb"
15
+
16
+ add_group "Core", "lib/funicular"
17
+ add_group "SSR", "lib/funicular/ssr"
18
+ add_group "Rails", %r{lib/funicular/(railtie|middleware|helpers|commands|tasks)}
19
+ add_group "Generators", "lib/generators"
20
+ end
21
+ end
22
+
3
23
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
24
  require "funicular"
5
25
  require "minitest/autorun"