funicular 0.2.1 → 0.4.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 +111 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +8 -8
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +8 -5
- data/demo/test_router.html +11 -11
- data/demo/tic-tac-toe.html +4 -4
- data/docs/architecture.md +80 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +3 -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} +626 -486
- 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 +675 -449
- 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 +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 +860 -567
- 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 +25 -25
- 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/dsl_test.rb +237 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
- data/minitest/funicular_test.rb +28 -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/sig_tags_test.rb +30 -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/0_tags.rb +62 -0
- data/mrblib/component.rb +255 -244
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +11 -13
- 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 +107 -21
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +106 -0
- data/sig/component.rbs +47 -84
- 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 +19 -7
- data/sig/tags.rbs +54 -0
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +60 -0
- metadata +22 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/lib/funicular/version.rb
CHANGED
|
@@ -55,7 +55,7 @@ class FunicularChatComponent < Funicular::Component
|
|
|
55
55
|
def subscribe
|
|
56
56
|
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
57
57
|
@subscription = @consumer.subscriptions.create(channel: "FunicularChatChannel") do |message|
|
|
58
|
-
patch(messages: state
|
|
58
|
+
patch(messages: state[:messages] + [message])
|
|
59
59
|
end
|
|
60
60
|
@subscription.on_connected do
|
|
61
61
|
patch(connected: true)
|
|
@@ -72,10 +72,10 @@ class FunicularChatComponent < Funicular::Component
|
|
|
72
72
|
|
|
73
73
|
def send_message(event = nil)
|
|
74
74
|
event.preventDefault if event
|
|
75
|
-
return if state
|
|
75
|
+
return if state[:sending]
|
|
76
76
|
|
|
77
|
-
name = state
|
|
78
|
-
body = state
|
|
77
|
+
name = state[:name].to_s.strip
|
|
78
|
+
body = state[:body].to_s.strip
|
|
79
79
|
if name == "" || body == ""
|
|
80
80
|
patch(error: "Name and message are required")
|
|
81
81
|
return
|
|
@@ -95,37 +95,37 @@ class FunicularChatComponent < Funicular::Component
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def render
|
|
98
|
-
div(class:
|
|
99
|
-
div(class:
|
|
100
|
-
div(class:
|
|
101
|
-
h1(class:
|
|
102
|
-
p(class:
|
|
103
|
-
state
|
|
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
|
|
103
|
+
state[:connected] ? "Realtime Ruby chat over ActionCable" : "Connecting..."
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
div(class:
|
|
108
|
-
if state
|
|
109
|
-
p(class:
|
|
107
|
+
div(class: styles.messages) do
|
|
108
|
+
if state[:messages].empty?
|
|
109
|
+
p(class: styles.empty) { "No messages yet." }
|
|
110
110
|
else
|
|
111
|
-
state
|
|
112
|
-
div(class:
|
|
113
|
-
div(class:
|
|
114
|
-
div(class:
|
|
111
|
+
state[:messages].each do |message|
|
|
112
|
+
div(class: styles.message, key: message["id"]) do
|
|
113
|
+
div(class: styles.name) { message["name"] }
|
|
114
|
+
div(class: styles.body) { message["body"] }
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
form(onsubmit: :send_message, class:
|
|
121
|
-
if state
|
|
122
|
-
div(class:
|
|
120
|
+
form(onsubmit: :send_message, class: styles.form) do
|
|
121
|
+
if state[:error]
|
|
122
|
+
div(class: styles.error) { state[:error] }
|
|
123
123
|
end
|
|
124
|
-
div(class:
|
|
125
|
-
input(type: "text", value: state
|
|
126
|
-
input(type: "text", value: state
|
|
127
|
-
button(type: "submit", class:
|
|
128
|
-
state
|
|
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
|
|
128
|
+
state[:sending] ? "Sending..." : "Send"
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
end
|
data/lib/tasks/funicular.rake
CHANGED
|
@@ -29,7 +29,7 @@ namespace :funicular do
|
|
|
29
29
|
rescue Funicular::Plugin::Error => e
|
|
30
30
|
puts "ERROR: #{e.message}"
|
|
31
31
|
exit 1
|
|
32
|
-
rescue Funicular::Compiler::
|
|
32
|
+
rescue Funicular::Compiler::MrbcMissingError => e
|
|
33
33
|
puts "ERROR: #{e.message}"
|
|
34
34
|
exit 1
|
|
35
35
|
rescue => e
|
|
@@ -0,0 +1,97 @@
|
|
|
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/commands/routes"
|
|
9
|
+
|
|
10
|
+
# Exercises the `funicular:routes` CLI command: its guard clauses (not a Rails
|
|
11
|
+
# app / no initializer / no routes) and the aligned routes table it prints.
|
|
12
|
+
class CommandsRoutesTest < Minitest::Test
|
|
13
|
+
def setup
|
|
14
|
+
Rails.reset_stub!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def teardown
|
|
18
|
+
Rails.reset_stub!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Runs Routes#execute inside a throwaway app dir, capturing stdout and the
|
|
22
|
+
# SystemExit status (nil when the command runs to completion).
|
|
23
|
+
def run_in_app(files: {})
|
|
24
|
+
Dir.mktmpdir do |dir|
|
|
25
|
+
files.each do |rel, contents|
|
|
26
|
+
path = File.join(dir, rel)
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, contents)
|
|
29
|
+
end
|
|
30
|
+
Rails.root = Pathname(dir)
|
|
31
|
+
|
|
32
|
+
status = nil
|
|
33
|
+
out = +""
|
|
34
|
+
Dir.chdir(dir) do
|
|
35
|
+
out, = capture_io do
|
|
36
|
+
begin
|
|
37
|
+
Funicular::Commands::Routes.new.execute
|
|
38
|
+
rescue SystemExit => e
|
|
39
|
+
status = e.status
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
yield out, status
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_errors_when_not_in_a_rails_app
|
|
48
|
+
run_in_app(files: {}) do |out, status|
|
|
49
|
+
assert_equal 1, status
|
|
50
|
+
assert_includes out, "Not in a Rails application"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_reports_when_initializer_is_missing
|
|
55
|
+
run_in_app(files: { "config/application.rb" => "# app\n" }) do |out, status|
|
|
56
|
+
assert_equal 0, status
|
|
57
|
+
assert_includes out, "No Funicular routes found"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_reports_when_initializer_defines_no_routes
|
|
62
|
+
run_in_app(files: {
|
|
63
|
+
"config/application.rb" => "# app\n",
|
|
64
|
+
"app/funicular/initializer.rb" => "# nothing here\n"
|
|
65
|
+
}) do |out, status|
|
|
66
|
+
assert_equal 0, status
|
|
67
|
+
assert_includes out, "No routes defined"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_prints_aligned_table_with_totals
|
|
72
|
+
initializer = <<~RUBY
|
|
73
|
+
router.get("/", to: HomeComponent, as: "home")
|
|
74
|
+
router.get("/channels/:id", to: ChannelComponent)
|
|
75
|
+
RUBY
|
|
76
|
+
|
|
77
|
+
run_in_app(files: {
|
|
78
|
+
"config/application.rb" => "# app\n",
|
|
79
|
+
"app/funicular/initializer.rb" => initializer
|
|
80
|
+
}) do |out, status|
|
|
81
|
+
assert_nil status # ran to completion, no exit
|
|
82
|
+
assert_includes out, "Method"
|
|
83
|
+
assert_includes out, "Component"
|
|
84
|
+
assert_includes out, "HomeComponent"
|
|
85
|
+
assert_includes out, "home_path"
|
|
86
|
+
assert_includes out, "/channels/:id"
|
|
87
|
+
assert_includes out, "Total: 2 routes"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_total_is_singular_for_one_route
|
|
92
|
+
routes = [{ method: "GET", path: "/", component: "HomeComponent", helper: nil }]
|
|
93
|
+
out, = capture_io { Funicular::Commands::Routes.new.send(:print_routes_table, routes) }
|
|
94
|
+
assert_includes out, "Total: 1 route"
|
|
95
|
+
refute_includes out, "1 routes"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
require "test_helper"
|
|
7
|
+
require_relative "support/rails_stub"
|
|
8
|
+
|
|
9
|
+
# Exercises the environment-independent parts of Funicular::Compiler: the
|
|
10
|
+
# source-file discovery/ordering (shared with the SSR runtime), the `node`
|
|
11
|
+
# executable lookup, and the vendored-mrbc availability guard. The actual
|
|
12
|
+
# spawn of mrbc via Node is integration-only and not covered here.
|
|
13
|
+
class CompilerTest < Minitest::Test
|
|
14
|
+
def make_compiler(source_dir: "/tmp/app", output_file: "/tmp/out.mrb", **opts)
|
|
15
|
+
Funicular::Compiler.new(source_dir: source_dir, output_file: output_file, **opts)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# --- source_files ordering -------------------------------------------
|
|
19
|
+
|
|
20
|
+
def test_source_files_orders_models_stores_components_then_initializers
|
|
21
|
+
Dir.mktmpdir do |dir|
|
|
22
|
+
%w[models stores components].each { |d| FileUtils.mkdir_p(File.join(dir, d)) }
|
|
23
|
+
File.write(File.join(dir, "models", "b_model.rb"), "")
|
|
24
|
+
File.write(File.join(dir, "models", "a_model.rb"), "")
|
|
25
|
+
File.write(File.join(dir, "stores", "s_store.rb"), "")
|
|
26
|
+
File.write(File.join(dir, "components", "c_component.rb"), "")
|
|
27
|
+
File.write(File.join(dir, "app_initializer.rb"), "")
|
|
28
|
+
File.write(File.join(dir, "initializer.rb"), "")
|
|
29
|
+
|
|
30
|
+
files = Funicular::Compiler.source_files(dir).map { |f| f.sub("#{dir}/", "") }
|
|
31
|
+
|
|
32
|
+
assert_equal(
|
|
33
|
+
[
|
|
34
|
+
"models/a_model.rb",
|
|
35
|
+
"models/b_model.rb",
|
|
36
|
+
"stores/s_store.rb",
|
|
37
|
+
"components/c_component.rb",
|
|
38
|
+
"app_initializer.rb",
|
|
39
|
+
"initializer.rb"
|
|
40
|
+
],
|
|
41
|
+
files
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_source_files_recurses_into_subdirectories
|
|
47
|
+
Dir.mktmpdir do |dir|
|
|
48
|
+
FileUtils.mkdir_p(File.join(dir, "components", "nested"))
|
|
49
|
+
File.write(File.join(dir, "components", "nested", "deep_component.rb"), "")
|
|
50
|
+
|
|
51
|
+
files = Funicular::Compiler.source_files(dir)
|
|
52
|
+
|
|
53
|
+
assert_equal 1, files.size
|
|
54
|
+
assert_match %r{components/nested/deep_component\.rb\z}, files.first
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_source_files_empty_for_missing_directory
|
|
59
|
+
assert_equal [], Funicular::Compiler.source_files("/no/such/dir")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- node executable lookup ------------------------------------------
|
|
63
|
+
|
|
64
|
+
def test_node_command_prefers_node_env_var
|
|
65
|
+
original = ENV["NODE"]
|
|
66
|
+
ENV["NODE"] = "/custom/bin/node"
|
|
67
|
+
assert_equal "/custom/bin/node", make_compiler.send(:node_command)
|
|
68
|
+
ensure
|
|
69
|
+
ENV["NODE"] = original
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_which_finds_executable_on_path
|
|
73
|
+
Dir.mktmpdir do |dir|
|
|
74
|
+
exe = File.join(dir, "faketool")
|
|
75
|
+
File.write(exe, "#!/bin/sh\n")
|
|
76
|
+
File.chmod(0o755, exe)
|
|
77
|
+
|
|
78
|
+
original = ENV["PATH"]
|
|
79
|
+
ENV["PATH"] = dir
|
|
80
|
+
assert_equal exe, make_compiler.send(:which, "faketool")
|
|
81
|
+
assert_nil make_compiler.send(:which, "does_not_exist_tool")
|
|
82
|
+
ensure
|
|
83
|
+
ENV["PATH"] = original
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- availability guard ----------------------------------------------
|
|
88
|
+
|
|
89
|
+
def test_compile_raises_when_vendored_mrbc_missing
|
|
90
|
+
skip "vendored mrbc present in this checkout" if File.exist?(Funicular::Compiler::MRBC_JS)
|
|
91
|
+
|
|
92
|
+
error = assert_raises(Funicular::Compiler::MrbcMissingError) { make_compiler.compile }
|
|
93
|
+
assert_includes error.message, "Vendored mrbc not found"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_prepend_source_files_are_coerced_to_strings
|
|
97
|
+
compiler = make_compiler(prepend_source_files: [Pathname.new("/a.rb"), :b])
|
|
98
|
+
assert_equal ["/a.rb", "b"], compiler.prepend_source_files
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- source gathering (writes the FUNICULAR_ENV temp file) -----------
|
|
102
|
+
|
|
103
|
+
def test_gather_source_files_prepends_extras_and_writes_env_file
|
|
104
|
+
Rails.reset_stub!
|
|
105
|
+
Rails.env_name = "test"
|
|
106
|
+
Dir.mktmpdir do |dir|
|
|
107
|
+
FileUtils.mkdir_p(File.join(dir, "components"))
|
|
108
|
+
File.write(File.join(dir, "components", "a_component.rb"), "")
|
|
109
|
+
output = File.join(dir, "build", "app.mrb")
|
|
110
|
+
|
|
111
|
+
compiler = make_compiler(source_dir: dir, output_file: output,
|
|
112
|
+
prepend_source_files: ["/plugin/x.rb"])
|
|
113
|
+
compiler.send(:gather_source_files)
|
|
114
|
+
|
|
115
|
+
gathered = compiler.instance_variable_get(:@source_files)
|
|
116
|
+
assert_equal "/plugin/x.rb", gathered.first
|
|
117
|
+
assert(gathered.any? { |f| f.end_with?("a_component.rb") })
|
|
118
|
+
|
|
119
|
+
env_file = compiler.instance_variable_get(:@env_file)
|
|
120
|
+
assert_match(/FUNICULAR_ENV.*test/, File.read(env_file))
|
|
121
|
+
ensure
|
|
122
|
+
Rails.reset_stub!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_gather_source_files_raises_without_ruby_files
|
|
127
|
+
Dir.mktmpdir do |dir|
|
|
128
|
+
compiler = make_compiler(source_dir: dir, output_file: File.join(dir, "out.mrb"))
|
|
129
|
+
error = assert_raises(RuntimeError) { compiler.send(:gather_source_files) }
|
|
130
|
+
assert_includes error.message, "No Ruby files found"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- compile_to_mrb (spawns the mrbc command) ------------------------
|
|
135
|
+
#
|
|
136
|
+
# `true`/`false` stand in for `node mrbc.js ...`: both ignore their argv, so
|
|
137
|
+
# the exit status alone drives the success/failure branches without needing a
|
|
138
|
+
# real Node/WASM toolchain.
|
|
139
|
+
|
|
140
|
+
def prepared_compiler(dir, node:, debug_mode: false)
|
|
141
|
+
src = File.join(dir, "a.rb")
|
|
142
|
+
File.write(src, "")
|
|
143
|
+
compiler = make_compiler(source_dir: dir, output_file: File.join(dir, "out.mrb"),
|
|
144
|
+
debug_mode: debug_mode)
|
|
145
|
+
compiler.instance_variable_set(:@node_command, node)
|
|
146
|
+
compiler.instance_variable_set(:@source_files, [src])
|
|
147
|
+
compiler.instance_variable_set(:@env_file, nil)
|
|
148
|
+
compiler
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_compile_to_mrb_succeeds_when_command_exits_zero
|
|
152
|
+
Dir.mktmpdir do |dir|
|
|
153
|
+
compiler = prepared_compiler(dir, node: "/bin/true", debug_mode: true)
|
|
154
|
+
out, = capture_io { compiler.send(:compile_to_mrb) }
|
|
155
|
+
assert_includes out, "Successfully compiled"
|
|
156
|
+
assert_includes out, "Debug mode: true"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def test_compile_to_mrb_raises_when_command_fails
|
|
161
|
+
Dir.mktmpdir do |dir|
|
|
162
|
+
compiler = prepared_compiler(dir, node: "/bin/false")
|
|
163
|
+
error = nil
|
|
164
|
+
capture_io do
|
|
165
|
+
error = assert_raises(RuntimeError) { compiler.send(:compile_to_mrb) }
|
|
166
|
+
end
|
|
167
|
+
assert_includes error.message, "Failed to compile with mrbc"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def test_compile_to_mrb_deletes_env_file_unless_kept
|
|
172
|
+
Dir.mktmpdir do |dir|
|
|
173
|
+
compiler = prepared_compiler(dir, node: "/bin/true")
|
|
174
|
+
env_file = File.join(dir, "out.mrb.env.rb")
|
|
175
|
+
File.write(env_file, "ENV['FUNICULAR_ENV'] = 'test'\n")
|
|
176
|
+
compiler.instance_variable_set(:@env_file, env_file)
|
|
177
|
+
|
|
178
|
+
capture_io { compiler.send(:compile_to_mrb) }
|
|
179
|
+
refute File.exist?(env_file), "temp env file should be removed"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_logger_is_used_when_provided
|
|
184
|
+
Dir.mktmpdir do |dir|
|
|
185
|
+
messages = []
|
|
186
|
+
fake_logger = Object.new
|
|
187
|
+
fake_logger.define_singleton_method(:info) { |m| messages << m }
|
|
188
|
+
compiler = prepared_compiler(dir, node: "/bin/true")
|
|
189
|
+
compiler.instance_variable_set(:@logger, fake_logger)
|
|
190
|
+
|
|
191
|
+
compiler.send(:compile_to_mrb)
|
|
192
|
+
assert(messages.any? { |m| m.include?("Successfully compiled") })
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Exercises Funicular::Configuration: the per-environment source selection, the
|
|
6
|
+
# source allowlist validation, and the cdn_version fallback to the vendored
|
|
7
|
+
# wasm version.
|
|
8
|
+
class ConfigurationTest < Minitest::Test
|
|
9
|
+
def setup
|
|
10
|
+
@config = Funicular::Configuration.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_defaults
|
|
14
|
+
assert_equal :local_debug, @config.development_source
|
|
15
|
+
assert_equal :local_debug, @config.test_source
|
|
16
|
+
assert_equal :local_dist, @config.production_source
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_source_for_known_environments
|
|
20
|
+
assert_equal :local_debug, @config.source_for("development")
|
|
21
|
+
assert_equal :local_debug, @config.source_for("test")
|
|
22
|
+
assert_equal :local_dist, @config.source_for("production")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_source_for_accepts_symbols
|
|
26
|
+
assert_equal :local_dist, @config.source_for(:production)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_source_for_unknown_environment_falls_back_to_development
|
|
30
|
+
assert_equal :local_debug, @config.source_for("staging")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_setters_accept_valid_sources
|
|
34
|
+
@config.development_source = :cdn
|
|
35
|
+
@config.test_source = "local_dist"
|
|
36
|
+
@config.production_source = :cdn
|
|
37
|
+
|
|
38
|
+
assert_equal :cdn, @config.development_source
|
|
39
|
+
assert_equal :local_dist, @config.test_source
|
|
40
|
+
assert_equal :cdn, @config.source_for("production")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_setters_reject_invalid_sources
|
|
44
|
+
error = assert_raises(ArgumentError) { @config.production_source = :nonsense }
|
|
45
|
+
assert_includes error.message, "Invalid Funicular source"
|
|
46
|
+
assert_includes error.message, "nonsense"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_cdn_version_prefers_explicit_value
|
|
50
|
+
@config.cdn_version = "9.9.9"
|
|
51
|
+
assert_equal "9.9.9", @config.cdn_version
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_cdn_version_falls_back_to_vendored_wasm_version
|
|
55
|
+
# No explicit version set -> delegates to Funicular.vendored_wasm_version
|
|
56
|
+
# (nil in a source checkout without vendored artifacts).
|
|
57
|
+
vendored = Funicular.vendored_wasm_version
|
|
58
|
+
if vendored.nil?
|
|
59
|
+
assert_nil @config.cdn_version
|
|
60
|
+
else
|
|
61
|
+
assert_equal vendored, @config.cdn_version
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
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_unknown_style_raises
|
|
168
|
+
klass = Class.new(Funicular::Component) do
|
|
169
|
+
styles do
|
|
170
|
+
shell "app-shell"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render
|
|
174
|
+
div { "x" }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
component = klass.new
|
|
178
|
+
|
|
179
|
+
assert_raises(NoMethodError) { component.styles.typo_name }
|
|
180
|
+
assert_raises(ArgumentError) { component.styles[:typo] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_bareword_helper_call_inside_styles_block_raises
|
|
184
|
+
assert_raises(ArgumentError) do
|
|
185
|
+
Class.new(Funicular::Component) do
|
|
186
|
+
styles do
|
|
187
|
+
button base: helper_that_does_not_exist("x")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_duplicate_style_definition_raises
|
|
194
|
+
assert_raises(ArgumentError) do
|
|
195
|
+
Class.new(Funicular::Component) do
|
|
196
|
+
styles do
|
|
197
|
+
shell "a"
|
|
198
|
+
shell "b"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_suspense_fallback_runs_bareword_in_own_component
|
|
205
|
+
klass = Class.new(Funicular::Component) do
|
|
206
|
+
use_suspense :user, ->(resolve, _reject) { resolve.call("u") }
|
|
207
|
+
|
|
208
|
+
def render
|
|
209
|
+
div do
|
|
210
|
+
suspense(:user, fallback: -> { span { "loading" } }) do |res|
|
|
211
|
+
span { res[:user] }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
component = klass.new
|
|
218
|
+
vnode = component.build_vdom
|
|
219
|
+
assert_equal "loading", vnode.children.first.children.first
|
|
220
|
+
|
|
221
|
+
component.instance_variable_get(:@suspense_data)[:user] = "u"
|
|
222
|
+
component.instance_variable_get(:@suspense_states)[:user] = :resolved
|
|
223
|
+
vnode = component.build_vdom
|
|
224
|
+
assert_equal "u", vnode.children.first.children.first
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_error_boundary_fallback_receives_view_context
|
|
228
|
+
fallback = ->(h, error) { h.div(class: "custom-error") { error.message } }
|
|
229
|
+
boundary = Funicular::ErrorBoundary.new(fallback: fallback)
|
|
230
|
+
boundary.catch_error(RuntimeError.new("boom"))
|
|
231
|
+
vnode = boundary.build_vdom
|
|
232
|
+
|
|
233
|
+
assert_equal "div", vnode.tag
|
|
234
|
+
assert_equal "custom-error", vnode.props[:class]
|
|
235
|
+
assert_equal ["boom"], vnode.children
|
|
236
|
+
end
|
|
237
|
+
end
|