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,267 @@
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/testing"
9
+
10
+ # Exercises the CRuby-side glue around the PicoRuby ("picotest") client test
11
+ # runner: the Result value object's tally math, the assertion bridging in
12
+ # Funicular::Testing.assert_picotests, and NodeRunner's pure output helpers.
13
+ # The actual Node/WASM subprocess launch (NodeRunner#run, Testing.run!) is
14
+ # integration-only and not exercised here.
15
+ class TestingTest < Minitest::Test
16
+ Runner = Funicular::Testing::NodeRunner
17
+ Result = Runner::Result
18
+
19
+ # Stand-in for Process::Status: success? is all Result reads from it.
20
+ Status = Struct.new(:ok) do
21
+ def success? = ok
22
+ end
23
+
24
+ # Minimal Minitest::Test stand-in so assert_picotests can drive `assert`
25
+ # and mutate `assertions` without touching this test's own counters.
26
+ class FakeTestCase
27
+ attr_accessor :assertions
28
+
29
+ def initialize
30
+ @assertions = 0
31
+ end
32
+
33
+ def assert(cond, msg = nil)
34
+ @assertions += 1
35
+ raise Minitest::Assertion, msg.to_s unless cond
36
+
37
+ true
38
+ end
39
+ end
40
+
41
+ def result(picotest_results, ok: true, stdout: "out", stderr: "")
42
+ Result.new(status: Status.new(ok), stdout: stdout, stderr: stderr,
43
+ picotest_results: picotest_results)
44
+ end
45
+
46
+ def sample_results
47
+ {
48
+ "AlphaTest" => { "success_count" => 3, "failures" => [{}], "skipped_count" => 1 },
49
+ "BetaTest" => { "success_count" => 2, "exceptions" => [{}], "crashes" => [{}] }
50
+ }
51
+ end
52
+
53
+ # --- Result tallies ---------------------------------------------------
54
+
55
+ def test_assertion_count_sums_successes_failures_exceptions_crashes
56
+ # 3 + 1 (fail) + 2 + 1 (exc) + 1 (crash) = 8
57
+ assert_equal 8, result(sample_results).picotest_assertion_count
58
+ end
59
+
60
+ def test_skip_and_test_counts
61
+ r = result(sample_results)
62
+ assert_equal 1, r.picotest_skip_count
63
+ assert_equal 9, r.picotest_test_count # 8 assertions + 1 skip
64
+ end
65
+
66
+ def test_failure_exception_crash_counts
67
+ r = result(sample_results)
68
+ assert_equal 1, r.picotest_failure_count
69
+ assert_equal 1, r.picotest_exception_count
70
+ assert_equal 1, r.picotest_crash_count
71
+ end
72
+
73
+ def test_counts_tolerate_missing_keys_and_nil_results
74
+ empty = result(nil)
75
+ assert_equal 0, empty.picotest_assertion_count
76
+ assert_equal 0, empty.picotest_skip_count
77
+
78
+ sparse = result({ "T" => {} })
79
+ assert_equal 0, sparse.picotest_assertion_count
80
+ end
81
+
82
+ def test_success_and_output
83
+ assert_predicate result({}, ok: true), :success?
84
+ refute_predicate result({}, ok: false), :success?
85
+
86
+ # output joins non-empty stdout/stderr with a newline.
87
+ assert_equal "out\nboom", result({}, stdout: "out", stderr: "boom").output
88
+ assert_equal "out", result({}, stdout: "out", stderr: "").output
89
+ end
90
+
91
+ def test_summary_reports_every_dimension
92
+ summary = result(sample_results).picotest_summary
93
+ assert_includes summary, "9 tests"
94
+ assert_includes summary, "8 assertions"
95
+ assert_includes summary, "1 failures"
96
+ assert_includes summary, "1 exceptions"
97
+ assert_includes summary, "1 crashes"
98
+ assert_includes summary, "1 skips"
99
+ end
100
+
101
+ # --- Testing.assert_picotests ----------------------------------------
102
+
103
+ def test_assert_picotests_passes_and_bridges_assertion_count
104
+ tc = FakeTestCase.new
105
+ out, = capture_io do
106
+ Funicular::Testing.assert_picotests(tc, result(sample_results))
107
+ end
108
+
109
+ assert_includes out, "Funicular picotest:"
110
+ # 1 for the pass/fail `assert`, plus (assertion_count - 1) bridged.
111
+ assert_equal 8, tc.assertions
112
+ end
113
+
114
+ def test_assert_picotests_can_suppress_summary
115
+ tc = FakeTestCase.new
116
+ out, = capture_io do
117
+ Funicular::Testing.assert_picotests(tc, result(sample_results), print_summary: false)
118
+ end
119
+ assert_equal "", out
120
+ end
121
+
122
+ def test_assert_picotests_fails_with_output_when_run_failed
123
+ tc = FakeTestCase.new
124
+ r = result({}, ok: false, stdout: "boom detail")
125
+ error = nil
126
+ capture_io do
127
+ error = assert_raises(Minitest::Assertion) do
128
+ Funicular::Testing.assert_picotests(tc, r)
129
+ end
130
+ end
131
+ assert_includes error.message, "boom detail"
132
+ end
133
+
134
+ # --- NodeRunner pure helpers -----------------------------------------
135
+
136
+ def test_strip_ansi_removes_color_codes
137
+ runner = Runner.new(app_root: Dir.pwd)
138
+ assert_equal "hello", runner.send(:strip_ansi, "\e[32mhello\e[0m")
139
+ end
140
+
141
+ def test_extract_picotest_results_pulls_marker_line_out_of_stdout
142
+ runner = Runner.new(app_root: Dir.pwd)
143
+ marker = Runner::RESULT_MARKER
144
+ stdout = "before\n#{marker}{\"AlphaTest\":{\"success_count\":1}}\nafter\n"
145
+
146
+ results, remaining = runner.send(:extract_picotest_results, stdout)
147
+
148
+ assert_equal({ "AlphaTest" => { "success_count" => 1 } }, results)
149
+ assert_equal "before\nafter\n", remaining
150
+ end
151
+
152
+ def test_extract_picotest_results_ignores_malformed_marker
153
+ runner = Runner.new(app_root: Dir.pwd)
154
+ stdout = "#{Runner::RESULT_MARKER}not-json\n"
155
+
156
+ results, remaining = runner.send(:extract_picotest_results, stdout)
157
+
158
+ assert_equal({}, results)
159
+ assert_equal stdout, remaining
160
+ end
161
+
162
+ def test_initialize_defaults_derive_source_dir_from_app_root
163
+ Dir.mktmpdir do |dir|
164
+ runner = Runner.new(app_root: dir)
165
+ assert_equal File.join(dir, "app", "funicular"), runner.source_dir
166
+ assert_equal Runner::DEFAULT_TEST_GLOB, runner.test_glob
167
+ end
168
+ end
169
+
170
+ # --- NodeRunner file gathering / manifest ----------------------------
171
+
172
+ def test_source_files_skips_initializers_for_a_generic_source_dir
173
+ Dir.mktmpdir do |dir|
174
+ source = File.join(dir, "src")
175
+ FileUtils.mkdir_p(File.join(source, "components"))
176
+ File.write(File.join(source, "components", "a_component.rb"), "")
177
+ File.write(File.join(source, "top.rb"), "")
178
+ File.write(File.join(source, "initializer.rb"), "")
179
+ File.write(File.join(source, "app_initializer.rb"), "")
180
+
181
+ runner = Runner.new(app_root: dir, source_dir: source)
182
+ basenames = runner.send(:source_files).map { |f| File.basename(f) }
183
+
184
+ assert_includes basenames, "a_component.rb"
185
+ assert_includes basenames, "top.rb"
186
+ refute_includes basenames, "initializer.rb"
187
+ refute_includes basenames, "app_initializer.rb"
188
+ end
189
+ end
190
+
191
+ def test_source_files_empty_when_dir_absent
192
+ Dir.mktmpdir do |dir|
193
+ runner = Runner.new(app_root: dir, source_dir: File.join(dir, "missing"))
194
+ assert_equal [], runner.send(:source_files)
195
+ end
196
+ end
197
+
198
+ def test_test_files_are_globbed_from_app_root
199
+ Dir.mktmpdir do |dir|
200
+ target = File.join(dir, "test", "funicular", "client")
201
+ FileUtils.mkdir_p(target)
202
+ File.write(File.join(target, "x_picotest.rb"), "")
203
+ File.write(File.join(target, "not_a_test.rb"), "")
204
+
205
+ runner = Runner.new(app_root: dir)
206
+ found = runner.send(:test_files).map { |f| File.basename(f) }
207
+
208
+ assert_equal ["x_picotest.rb"], found
209
+ end
210
+ end
211
+
212
+ def test_build_manifest_shape
213
+ Dir.mktmpdir do |dir|
214
+ runner = Runner.new(app_root: dir, timeout_ms: 1234)
215
+ manifest = runner.send(:build_manifest)
216
+
217
+ assert_equal dir, manifest[:appRoot]
218
+ assert_equal 1234, manifest[:timeoutMs]
219
+ assert_includes manifest[:html], "<div id=\"app\">"
220
+ assert_kind_of Array, manifest[:sourceFiles]
221
+ assert_kind_of Array, manifest[:testFiles]
222
+ end
223
+ end
224
+
225
+ def test_with_manifest_file_writes_json_and_cleans_up
226
+ runner = Runner.new(app_root: Dir.pwd)
227
+ captured = nil
228
+ runner.send(:with_manifest_file, { appRoot: "/x", n: 1 }) do |path|
229
+ captured = path
230
+ assert File.exist?(path)
231
+ assert_equal({ "appRoot" => "/x", "n" => 1 }, JSON.parse(File.read(path)))
232
+ end
233
+ refute File.exist?(captured), "manifest tempfile should be unlinked"
234
+ end
235
+
236
+ def test_app_root_defaults_to_rails_root
237
+ Dir.mktmpdir do |dir|
238
+ Rails.reset_stub!
239
+ Rails.root = Pathname(dir)
240
+ assert_equal File.expand_path(dir), Runner.new.app_root
241
+ ensure
242
+ Rails.reset_stub!
243
+ end
244
+ end
245
+
246
+ def test_source_files_uses_compiler_order_for_a_rails_app_dir
247
+ Dir.mktmpdir do |dir|
248
+ app_funicular = File.join(dir, "app", "funicular")
249
+ FileUtils.mkdir_p(File.join(app_funicular, "models"))
250
+ FileUtils.mkdir_p(File.join(app_funicular, "components"))
251
+ File.write(File.join(app_funicular, "models", "m_model.rb"), "")
252
+ File.write(File.join(app_funicular, "components", "c_component.rb"), "")
253
+ File.write(File.join(app_funicular, "initializer.rb"), "")
254
+
255
+ runner = Runner.new(app_root: dir)
256
+ basenames = runner.send(:source_files).map { |f| File.basename(f) }
257
+
258
+ # Compiler order (models before components) and initializer excluded.
259
+ assert_equal ["m_model.rb", "c_component.rb"], basenames
260
+ end
261
+ end
262
+
263
+ def test_runner_js_points_at_the_bundled_mjs
264
+ runner = Runner.new(app_root: Dir.pwd)
265
+ assert_match %r{node_runner\.mjs\z}, runner.send(:runner_js)
266
+ end
267
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ViewContextTest < Minitest::Test
6
+ def setup
7
+ Funicular::SSR::Runtime.load_framework!
8
+ end
9
+
10
+ def test_explicit_state_styles_and_resources_do_not_collide_with_html_methods
11
+ klass = Class.new(Funicular::Component) do
12
+ styles { |css| css.define :hash, "hash-class" }
13
+
14
+ use_suspense :data, ->(resolve, _reject) { resolve.call("loaded") }
15
+
16
+ def initialize_state
17
+ { class: "state-class", hash: "state-hash" }
18
+ end
19
+
20
+ def render(h)
21
+ h.data(class: h.styles[:hash]) do
22
+ "#{state[:class]} #{state[:hash]} #{h.resources[:data]}"
23
+ end
24
+ end
25
+ end
26
+
27
+ component = klass.new
28
+ component.instance_variable_get(:@suspense_data)[:data] = "loaded"
29
+ component.instance_variable_get(:@suspense_states)[:data] = :resolved
30
+
31
+ vnode = component.build_vdom
32
+
33
+ assert_equal "data", vnode.tag
34
+ assert_equal "hash-class", vnode.props[:class]
35
+ assert_equal ["state-class state-hash loaded"], vnode.children
36
+ end
37
+
38
+ def test_component_children_are_vdom_children_not_props
39
+ child = Class.new(Funicular::Component) do
40
+ def render(h)
41
+ h.div { children }
42
+ end
43
+ end
44
+
45
+ parent = Class.new(Funicular::Component) do
46
+ define_method(:render) do |h|
47
+ h.component(child, title: "x") do |hh|
48
+ hh.span { "inside" }
49
+ end
50
+ end
51
+ end
52
+
53
+ vnode = parent.new.build_vdom
54
+
55
+ assert_instance_of Funicular::VDOM::Component, vnode
56
+ assert_equal({ title: "x" }, vnode.props)
57
+ refute_includes vnode.props.keys, :children_block
58
+ assert_equal 1, vnode.children.length
59
+ assert_equal "span", vnode.children.first.tag
60
+ end
61
+
62
+ def test_route_helpers_are_runtime_scoped
63
+ router_a = Funicular::Router.new(nil)
64
+ router_b = Funicular::Router.new(nil)
65
+ router_a.get("/users/:id", to: Class.new(Funicular::Component), as: "user")
66
+ router_b.get("/accounts/:id", to: Class.new(Funicular::Component), as: "user")
67
+
68
+ component_a = Class.new(Funicular::Component).new
69
+ component_b = Class.new(Funicular::Component).new
70
+ component_a.runtime = Funicular::Runtime.new(router_a)
71
+ component_b.runtime = Funicular::Runtime.new(router_b)
72
+
73
+ assert_equal "/users/7", Funicular::ViewContext.new(component_a).routes.user_path(7)
74
+ assert_equal "/accounts/7", Funicular::ViewContext.new(component_b).routes.user_path(7)
75
+ end
76
+
77
+ def test_child_collector_is_restored_when_render_raises
78
+ klass = Class.new(Funicular::Component) do
79
+ attr_reader :captured
80
+
81
+ def render(h)
82
+ begin
83
+ h.div do |hh|
84
+ hh.span { "before" }
85
+ raise "boom"
86
+ end
87
+ rescue
88
+ @captured = current_children
89
+ h.div { "after" }
90
+ end
91
+ end
92
+ end
93
+
94
+ instance = klass.new
95
+ vnode = instance.build_vdom
96
+
97
+ assert_nil instance.current_children
98
+ assert_equal "div", vnode.tag
99
+ assert_equal ["after"], vnode.children
100
+ end
101
+ end