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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +17 -17
- data/demo/test_component.html +15 -15
- data/demo/test_error_boundary.html +41 -41
- data/demo/test_router.html +57 -57
- data/demo/tic-tac-toe.html +29 -29
- data/docs/architecture.md +43 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +2 -0
- data/lib/funicular/ssr.rb +2 -1
- data/lib/funicular/testing/node_runner.rb +1 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -0
- data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +14 -1
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +16 -10
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +85 -84
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +47 -46
- data/lib/tasks/funicular.rake +1 -1
- data/minitest/commands_routes_test.rb +97 -0
- data/minitest/compiler_test.rb +195 -0
- data/minitest/configuration_test.rb +64 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +6 -6
- data/minitest/form_for_test.rb +2 -2
- data/minitest/funicular_test.rb +28 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/middleware_test.rb +154 -0
- data/minitest/picoruby_helper_test.rb +139 -0
- data/minitest/route_parser_test.rb +139 -0
- data/minitest/schema_test.rb +23 -0
- data/minitest/ssr_test.rb +57 -0
- data/minitest/support/rails_stub.rb +59 -0
- data/minitest/test_helper.rb +20 -0
- data/minitest/testing_test.rb +267 -0
- data/minitest/view_context_test.rb +101 -0
- data/mrblib/component.rb +158 -237
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +19 -27
- data/mrblib/form_builder.rb +28 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +12 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +13 -17
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +135 -0
- data/sig/component.rbs +34 -85
- data/sig/error_boundary.rbs +4 -4
- data/sig/form_builder.rbs +2 -1
- data/sig/html_serializer.rbs +2 -1
- data/sig/http.rbs +1 -0
- data/sig/patcher.rbs +1 -1
- data/sig/router.rbs +2 -13
- data/sig/runtime.rbs +13 -0
- data/sig/styles.rbs +3 -4
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +47 -0
- metadata +18 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- 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
|
data/minitest/schema_test.rb
CHANGED
|
@@ -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
|
data/minitest/test_helper.rb
CHANGED
|
@@ -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"
|