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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- 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 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- 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
|
|
Binary file
|