funicular 0.1.0 → 0.2.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +10 -2
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +23 -15
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/hydration_test.rb +87 -0
  45. data/minitest/plugin_test.rb +51 -0
  46. data/minitest/schema_test.rb +106 -0
  47. data/minitest/ssr_test.rb +94 -0
  48. data/minitest/validations_test.rb +183 -0
  49. data/mrbgem.rake +1 -0
  50. data/mrblib/0_validations.rb +206 -0
  51. data/mrblib/1_validators.rb +180 -0
  52. data/mrblib/cable.rb +24 -9
  53. data/mrblib/component.rb +172 -33
  54. data/mrblib/debug.rb +3 -0
  55. data/mrblib/differ.rb +47 -37
  56. data/mrblib/file_upload.rb +9 -1
  57. data/mrblib/form_builder.rb +21 -5
  58. data/mrblib/funicular.rb +97 -8
  59. data/mrblib/html_serializer.rb +121 -0
  60. data/mrblib/http.rb +123 -29
  61. data/mrblib/model.rb +50 -0
  62. data/mrblib/patcher.rb +74 -8
  63. data/mrblib/router.rb +40 -3
  64. data/mrblib/store.rb +304 -0
  65. data/mrblib/store_collection.rb +171 -0
  66. data/mrblib/store_singleton.rb +79 -0
  67. data/sig/cable.rbs +1 -0
  68. data/sig/component.rbs +13 -5
  69. data/sig/funicular.rbs +14 -1
  70. data/sig/html_serializer.rbs +20 -0
  71. data/sig/http.rbs +21 -6
  72. data/sig/model.rbs +6 -1
  73. data/sig/patcher.rbs +4 -1
  74. data/sig/router.rbs +3 -2
  75. data/sig/store.rbs +89 -0
  76. data/sig/store_collection.rbs +43 -0
  77. data/sig/store_singleton.rbs +19 -0
  78. data/sig/validations.rbs +103 -0
  79. data/sig/vdom.rbs +6 -6
  80. metadata +47 -12
  81. data/docs/README.md +0 -419
  82. data/docs/advanced-features.md +0 -632
  83. data/docs/components-and-state.md +0 -539
  84. data/docs/data-fetching.md +0 -528
  85. data/docs/forms.md +0 -446
  86. data/docs/rails-integration.md +0 -426
  87. data/docs/realtime.md +0 -543
  88. data/docs/routing-and-navigation.md +0 -427
  89. data/docs/styling.md +0 -285
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const manifestPath = process.argv[2];
9
+
10
+ if (!manifestPath) {
11
+ console.error('Usage: node node_runner.mjs <manifest.json>');
12
+ process.exit(1);
13
+ }
14
+
15
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
16
+ const appRequire = createRequire(join(manifest.appRoot, 'package.json'));
17
+ const RESULT_MARKER = '__FUNICULAR_TEST_RESULTS_JSON__=';
18
+
19
+ let JSDOM;
20
+ try {
21
+ ({ JSDOM } = appRequire('jsdom'));
22
+ } catch (error) {
23
+ console.error('Funicular client tests require jsdom.');
24
+ console.error('Install it in the Rails app with: npm install --save-dev jsdom');
25
+ console.error(error.message);
26
+ process.exit(1);
27
+ }
28
+
29
+ const runtimeDir = resolve(manifest.runtimeDir);
30
+ const modulePath = join(runtimeDir, 'picoruby.js');
31
+
32
+ function installDom() {
33
+ const dom = new JSDOM(manifest.html, {
34
+ url: manifest.url,
35
+ pretendToBeVisual: true,
36
+ runScripts: 'outside-only'
37
+ });
38
+
39
+ const win = dom.window;
40
+ globalThis.window = win;
41
+ globalThis.document = win.document;
42
+ globalThis.location = win.location;
43
+ globalThis.history = win.history;
44
+ Object.defineProperty(globalThis, 'navigator', { value: win.navigator, configurable: true });
45
+ Object.defineProperty(globalThis, 'localStorage', { value: win.localStorage, configurable: true });
46
+ globalThis.Event = win.Event;
47
+ globalThis.CustomEvent = win.CustomEvent;
48
+ globalThis.MouseEvent = win.MouseEvent;
49
+ globalThis.KeyboardEvent = win.KeyboardEvent;
50
+ globalThis.InputEvent = win.InputEvent;
51
+ globalThis.FormData = win.FormData;
52
+ globalThis.Element = win.Element;
53
+ globalThis.Document = win.Document;
54
+ globalThis.HTMLElement = win.HTMLElement;
55
+ globalThis.Node = win.Node;
56
+ globalThis.Text = win.Text;
57
+ globalThis.Response = globalThis.Response || win.Response;
58
+ globalThis.fetch = globalThis.fetch || win.fetch?.bind(win);
59
+ win.fetch = globalThis.fetch;
60
+
61
+ return dom;
62
+ }
63
+
64
+ function rubyString(value) {
65
+ return JSON.stringify(String(value));
66
+ }
67
+
68
+ function bootstrapRuby() {
69
+ const sourceFiles = manifest.sourceFiles.map(rubyString).join(', ');
70
+ const testFiles = manifest.testFiles.map(rubyString).join(', ');
71
+
72
+ return `
73
+ require 'json'
74
+ require 'picotest'
75
+ require 'funicular'
76
+
77
+ module Funicular
78
+ module Testing
79
+ class DOMTest < Picotest::Test
80
+ def setup
81
+ JS.eval("document.body.innerHTML = '<div id=\\\\\\"app\\\\\\"></div>'")
82
+ end
83
+
84
+ def document
85
+ JS.document
86
+ end
87
+
88
+ def container
89
+ document.getElementById('app')
90
+ end
91
+
92
+ def mount(component_class, props = {})
93
+ @component = component_class.new(props)
94
+ @component.mount(container)
95
+ drain
96
+ @component
97
+ end
98
+
99
+ def query(selector)
100
+ document.querySelector(selector)
101
+ end
102
+
103
+ def assert_selector(selector)
104
+ actual = query(selector)
105
+ report(!actual.nil?, "Expected selector #{selector.inspect} to exist", selector, actual)
106
+ end
107
+
108
+ def text(selector = nil)
109
+ target = selector ? query(selector) : document.body
110
+ target ? target[:textContent].to_s : ''
111
+ end
112
+
113
+ def assert_text(expected, selector = nil)
114
+ actual = text(selector)
115
+ report(
116
+ actual.include?(expected.to_s),
117
+ "Expected text to include #{expected.to_s.inspect}",
118
+ expected.to_s,
119
+ actual
120
+ )
121
+ end
122
+
123
+ def dispatch(selector, event_type)
124
+ script = "document.querySelector(" + JSON.generate(selector) + ")" \
125
+ + ".dispatchEvent(new Event(" + JSON.generate(event_type) \
126
+ + ", { bubbles: true, cancelable: true }))"
127
+ JS.eval(script)
128
+ drain
129
+ end
130
+
131
+ def click(selector)
132
+ dispatch(selector, 'click')
133
+ end
134
+
135
+ def submit(selector = 'form')
136
+ dispatch(selector, 'submit')
137
+ end
138
+
139
+ def input(selector, value)
140
+ JS.global[:__funicularTestingValue] = value.to_s
141
+ script = "document.querySelector(" + JSON.generate(selector) + ")" \
142
+ + ".value = globalThis.__funicularTestingValue"
143
+ JS.eval(script)
144
+ dispatch(selector, 'input')
145
+ ensure
146
+ JS.global[:__funicularTestingValue] = nil
147
+ end
148
+
149
+ def drain(ms = 20)
150
+ sleep_ms(ms) if respond_to?(:sleep_ms)
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ source_files = [${sourceFiles}]
157
+ test_files = [${testFiles}]
158
+
159
+ (source_files + test_files).each { |file| load file }
160
+
161
+ test_classes = Object.constants.map { |name| Object.const_get(name) }.select do |klass|
162
+ klass.class? &&
163
+ klass != Picotest::Test &&
164
+ klass != Funicular::Testing::DOMTest &&
165
+ klass.ancestors.include?(Picotest::Test)
166
+ end
167
+
168
+ results = {}
169
+
170
+ test_classes.each do |klass|
171
+ test = klass.new
172
+ test.list_tests.each do |method_name|
173
+ puts
174
+ print " #{klass}##{method_name} "
175
+ failure_count_before = test.result["failures"].size
176
+ exception_count_before = test.result["exceptions"].size
177
+ begin
178
+ test.setup
179
+ test.send(method_name)
180
+ rescue Picotest::Skip => e
181
+ test.report_skip({ method: method_name.to_s, reason: e.message })
182
+ rescue => e
183
+ test.report_exception({ method: method_name.to_s, raise_message: "#{e.class}: #{e.message}" })
184
+ ensure
185
+ begin
186
+ test.teardown
187
+ rescue => e
188
+ test.report_exception({ method: method_name.to_s, raise_message: "teardown #{e.class}: #{e.message}" })
189
+ end
190
+ test.clear_doubles
191
+ end
192
+ test.result["failures"][failure_count_before..-1].each do |failure|
193
+ failure[:test] ||= "#{klass}##{method_name}"
194
+ end
195
+ test.result["exceptions"][exception_count_before..-1].each do |exception|
196
+ exception[:test] ||= "#{klass}##{method_name}"
197
+ end
198
+ end
199
+ results[klass.to_s] = test.result
200
+ end
201
+
202
+ puts
203
+
204
+ JS.global[:__funicularTestResult] = JSON.generate(results)
205
+ JS.global[:__funicularTestDone] = true
206
+ `;
207
+ }
208
+
209
+ function summarize(results) {
210
+ let success = 0;
211
+ let failures = 0;
212
+ let exceptions = 0;
213
+ let crashes = 0;
214
+ let skips = 0;
215
+
216
+ for (const [name, result] of Object.entries(results)) {
217
+ const failureCount = result.failures?.length || 0;
218
+ const exceptionCount = result.exceptions?.length || 0;
219
+ const crashCount = result.crashes?.length || 0;
220
+ const skipCount = result.skipped_count || 0;
221
+ success += result.success_count || 0;
222
+ failures += failureCount;
223
+ exceptions += exceptionCount;
224
+ crashes += crashCount;
225
+ skips += skipCount;
226
+
227
+ console.log(`${name}: success=${result.success_count || 0}, failure=${failureCount}, exception=${exceptionCount}, crash=${crashCount}, skip=${skipCount}`);
228
+ for (const failure of result.failures || []) {
229
+ const location = failure.method ? ` (${failure.method})` : '';
230
+ console.log(` F ${failure.test || name}${location}: ${failure.error_message}`);
231
+ if (failure.expected !== undefined || failure.actual !== undefined) {
232
+ console.log(` expected: ${JSON.stringify(failure.expected)}`);
233
+ console.log(` actual: ${JSON.stringify(failure.actual)}`);
234
+ }
235
+ }
236
+ for (const exception of result.exceptions || []) {
237
+ console.log(` E ${exception.test || exception.method || name}: ${exception.raise_message}`);
238
+ }
239
+ }
240
+
241
+ console.log(`Total: success=${success}, failure=${failures}, exception=${exceptions}, crash=${crashes}, skip=${skips}`);
242
+ return failures + exceptions + crashes;
243
+ }
244
+
245
+ async function main() {
246
+ const { default: createModule } = await import(pathToFileURL(modulePath).href);
247
+ const Module = await createModule({
248
+ locateFile: (file) => join(runtimeDir, file),
249
+ print: (text) => process.stdout.write(text + '\n'),
250
+ printErr: (text) => process.stderr.write(text + '\n')
251
+ });
252
+
253
+ installDom();
254
+
255
+ const initResult = Module.ccall('picorb_init', 'number', [], []);
256
+ if (initResult !== 0) {
257
+ throw new Error('Failed to initialize PicoRuby');
258
+ }
259
+
260
+ const code = bootstrapRuby();
261
+ const createResult = Module.ccall(
262
+ 'picorb_create_task_with_filename',
263
+ 'number',
264
+ ['string', 'string'],
265
+ [code, 'funicular-client-tests.rb']
266
+ );
267
+ if (createResult !== 0) {
268
+ throw new Error('Failed to create Funicular test task');
269
+ }
270
+
271
+ const timeoutAt = Date.now() + Number(manifest.timeoutMs || 5000);
272
+ while (!(globalThis.__funicularTestDone || globalThis.window?.__funicularTestDone) && Date.now() < timeoutAt) {
273
+ Module.ccall('mrb_run_step', 'number', [], []);
274
+ Module.ccall('mrb_tick_wasm', null, [], []);
275
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 1));
276
+ }
277
+
278
+ if (!(globalThis.__funicularTestDone || globalThis.window?.__funicularTestDone)) {
279
+ console.error(`Funicular client tests timed out after ${manifest.timeoutMs}ms`);
280
+ process.exit(1);
281
+ }
282
+
283
+ const resultJson = globalThis.__funicularTestResult || globalThis.window?.__funicularTestResult || '{}';
284
+ const results = JSON.parse(resultJson);
285
+ console.log(`${RESULT_MARKER}${JSON.stringify(results)}`);
286
+ const errorCount = summarize(results);
287
+ process.exit(errorCount === 0 ? 0 : 1);
288
+ }
289
+
290
+ main().catch((error) => {
291
+ console.error(error.stack || error.message || String(error));
292
+ process.exit(1);
293
+ });
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "tempfile"
6
+
7
+ module Funicular
8
+ module Testing
9
+ class NodeRunner
10
+ RESULT_MARKER = "__FUNICULAR_TEST_RESULTS_JSON__="
11
+
12
+ Result = Struct.new(:status, :stdout, :stderr, :picotest_results, keyword_init: true) do
13
+ def success?
14
+ status.success?
15
+ end
16
+
17
+ def output
18
+ [stdout, stderr].reject(&:empty?).join("\n")
19
+ end
20
+
21
+ def picotest_assertion_count
22
+ each_picotest_result.sum do |_name, result|
23
+ result.fetch("success_count", 0) +
24
+ Array(result["failures"]).size +
25
+ Array(result["exceptions"]).size +
26
+ Array(result["crashes"]).size
27
+ end
28
+ end
29
+
30
+ def picotest_test_count
31
+ picotest_assertion_count + picotest_skip_count
32
+ end
33
+
34
+ def picotest_failure_count
35
+ each_picotest_result.sum { |_name, result| Array(result["failures"]).size }
36
+ end
37
+
38
+ def picotest_exception_count
39
+ each_picotest_result.sum { |_name, result| Array(result["exceptions"]).size }
40
+ end
41
+
42
+ def picotest_crash_count
43
+ each_picotest_result.sum { |_name, result| Array(result["crashes"]).size }
44
+ end
45
+
46
+ def picotest_skip_count
47
+ each_picotest_result.sum { |_name, result| result.fetch("skipped_count", 0) }
48
+ end
49
+
50
+ def picotest_summary
51
+ "Funicular picotest: #{picotest_test_count} tests, " \
52
+ "#{picotest_assertion_count} assertions, " \
53
+ "#{picotest_failure_count} failures, " \
54
+ "#{picotest_exception_count} exceptions, " \
55
+ "#{picotest_crash_count} crashes, " \
56
+ "#{picotest_skip_count} skips"
57
+ end
58
+
59
+ private
60
+
61
+ def each_picotest_result
62
+ (picotest_results || {}).each
63
+ end
64
+ end
65
+
66
+ DEFAULT_TEST_GLOB = "test/funicular/client/**/*_picotest.rb"
67
+
68
+ attr_reader :app_root, :source_dir, :test_glob, :runtime_dir, :node, :timeout_ms
69
+
70
+ def initialize(app_root: nil, source_dir: nil, test_glob: DEFAULT_TEST_GLOB,
71
+ runtime_dir: nil, node: nil, timeout_ms: 5000)
72
+ @app_root = File.expand_path(app_root || rails_root || Dir.pwd)
73
+ @source_dir = File.expand_path(source_dir || File.join(@app_root, "app", "funicular"))
74
+ @test_glob = test_glob
75
+ @runtime_dir = File.expand_path(runtime_dir || default_runtime_dir)
76
+ @node = node || ENV["NODE"] || "node"
77
+ @timeout_ms = timeout_ms
78
+ end
79
+
80
+ def run
81
+ manifest = build_manifest
82
+ with_manifest_file(manifest) do |path|
83
+ stdout, stderr, status = Open3.capture3(node, runner_js, path, chdir: app_root)
84
+ stdout = strip_ansi(stdout)
85
+ stderr = strip_ansi(stderr)
86
+ results, stdout = extract_picotest_results(stdout)
87
+ Result.new(status: status, stdout: stdout, stderr: stderr, picotest_results: results)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def strip_ansi(output)
94
+ output.gsub(/\e\[[0-9;]*m/, "")
95
+ end
96
+
97
+ def extract_picotest_results(output)
98
+ results = {}
99
+ lines = output.lines.reject do |line|
100
+ next false unless line.start_with?(RESULT_MARKER)
101
+
102
+ results = JSON.parse(line.delete_prefix(RESULT_MARKER))
103
+ true
104
+ rescue JSON::ParserError
105
+ false
106
+ end
107
+ [results, lines.join]
108
+ end
109
+
110
+ def rails_root
111
+ Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
112
+ end
113
+
114
+ def gem_root
115
+ File.expand_path("../../..", __dir__)
116
+ end
117
+
118
+ def default_runtime_dir
119
+ ENV["FUNICULAR_TEST_PICORUBY_DIR"] ||
120
+ existing_path(File.join(gem_root, "lib", "funicular", "vendor", "picoruby-test-node")) ||
121
+ existing_path(File.expand_path("../picoruby/build/picoruby-wasm-test/bin", gem_root)) ||
122
+ existing_path(File.expand_path("../../build/picoruby-wasm-test/bin", gem_root)) ||
123
+ File.join(gem_root, "lib", "funicular", "vendor", "picoruby-test-node")
124
+ end
125
+
126
+ def existing_path(path)
127
+ path if Dir.exist?(path)
128
+ end
129
+
130
+ def runner_js
131
+ File.expand_path("node_runner.mjs", __dir__)
132
+ end
133
+
134
+ def build_manifest
135
+ {
136
+ appRoot: app_root,
137
+ runtimeDir: runtime_dir,
138
+ timeoutMs: timeout_ms,
139
+ html: "<!doctype html><html><body><div id=\"app\"></div></body></html>",
140
+ url: "http://localhost/",
141
+ sourceFiles: source_files,
142
+ testFiles: test_files
143
+ }
144
+ end
145
+
146
+ def source_files
147
+ return [] unless Dir.exist?(source_dir)
148
+
149
+ files = if rails_app_source_dir? && defined?(Funicular::Compiler)
150
+ Funicular::Compiler.source_files(source_dir)
151
+ else
152
+ generic_source_files
153
+ end
154
+ app_files = files.reject { |path| File.basename(path) == "initializer.rb" || path.end_with?("_initializer.rb") }
155
+ plugin_source_files + app_files
156
+ end
157
+
158
+ def rails_app_source_dir?
159
+ source_dir == File.expand_path(File.join(app_root, "app", "funicular"))
160
+ end
161
+
162
+ def generic_source_files
163
+ nested = Dir.glob(File.join(source_dir, "*", "**", "*.rb")).sort
164
+ top_level = Dir.glob(File.join(source_dir, "*.rb")).sort
165
+ nested + top_level
166
+ end
167
+
168
+ def plugin_source_files
169
+ return [] unless defined?(Funicular::Plugin::Registry)
170
+
171
+ Funicular::Plugin::Registry.new(app_root).local_source_files
172
+ rescue Funicular::Plugin::Error
173
+ []
174
+ end
175
+
176
+ def test_files
177
+ Dir.glob(File.join(app_root, test_glob)).sort
178
+ end
179
+
180
+ def with_manifest_file(manifest)
181
+ file = Tempfile.new(["funicular-test-manifest", ".json"])
182
+ file.write(JSON.pretty_generate(manifest))
183
+ file.close
184
+ yield file.path
185
+ ensure
186
+ file&.unlink
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/node_runner"
4
+
5
+ module Funicular
6
+ module Testing
7
+ def self.run!(**options)
8
+ NodeRunner.new(**options).run
9
+ end
10
+
11
+ def self.assert_picotests(test_case, result, print_summary: true)
12
+ puts result.picotest_summary if print_summary
13
+ test_case.assert result.success?, result.output
14
+
15
+ # The Minitest wrapper is one CRuby test method, but the actual client
16
+ # checks run inside PicoRuby. Reflect those inner checks in Minitest's
17
+ # assertion count so successful runs do not look like a single assertion.
18
+ extra_assertions = result.picotest_assertion_count - 1
19
+ test_case.assertions += extra_assertions if extra_assertions.positive?
20
+ end
21
+ end
22
+ end