From 70ea53f0bebc881521a820ffaf5c86b74d9fcfbb Mon Sep 17 00:00:00 2001 From: Noah Gibbs Date: Fri, 5 Jul 2024 21:06:34 +0100 Subject: [PATCH] ShoesSpec support via HTML files --- Gemfile.lock | 1 + lib/scarpe/space_shoes.rb | 6 +- lib/space_shoes/guest/app.rb | 22 +++++- lib/space_shoes/guest/control_interface.rb | 9 +++ lib/space_shoes/guest/display_service.rb | 4 +- lib/space_shoes/guest/shoes-spec.rb | 86 ++++++++++++++++++++++ lib/space_shoes/guest/subscription_item.rb | 6 +- lib/space_shoes/guest/web_wrangler.rb | 20 +++-- packaging/Gemfile | 2 +- packaging/Gemfile.lock | 15 +++- space_shoes.gemspec | 3 + test/cache/Gemfile | 2 +- test/cache/Gemfile.lock | 12 ++- test/cache/spacewalk.js | 35 ++++----- test/cache/sspec_test.html | 20 +++++ test/test_shoes_spec.rb | 14 ++++ 16 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 lib/space_shoes/guest/shoes-spec.rb create mode 100644 test/cache/sspec_test.html create mode 100644 test/test_shoes_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 7a5262e..f4bcc10 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,6 +18,7 @@ PATH specs: space_shoes (0.1.0) lacci (~> 0.4.0) + minitest (~> 5.22) scarpe-components (~> 0.4.0) GEM diff --git a/lib/scarpe/space_shoes.rb b/lib/scarpe/space_shoes.rb index 8b589c5..588ee1b 100644 --- a/lib/scarpe/space_shoes.rb +++ b/lib/scarpe/space_shoes.rb @@ -31,10 +31,8 @@ "Monaco", ) -# When we require SpaceShoes' shoes-spec it will fill this in on the host side -module Scarpe; module Test; end; end -require "shoes-spec" -Shoes::Spec.instance = Scarpe::Test +require "space_shoes/guest/shoes-spec" +Shoes::Spec.instance = SpaceShoes::ShoesSpec require "scarpe/components/html" module SpaceShoes diff --git a/lib/space_shoes/guest/app.rb b/lib/space_shoes/guest/app.rb index 72ca6d0..05c5634 100644 --- a/lib/space_shoes/guest/app.rb +++ b/lib/space_shoes/guest/app.rb @@ -27,6 +27,26 @@ def initialize(properties) bind_shoes_event(event_name: "init") { init } bind_shoes_event(event_name: "run") { run } bind_shoes_event(event_name: "destroy") { destroy } + + # Run ShoesSpec tests on next heartbeat + @control_interface.on_event(:next_heartbeat) do + # If there's no error, this happens once. If an error is raised, keeps happening... + + ## Put the true/false pass/fail into a location where JS can access it + e = Object.new # fake parallel executor + def e.shutdown; end + def e.start; end + Minitest.parallel_executor = e # No threads available in ruby.wasm + result = Minitest.run [] + # TODO: add a DOM element or similar so we know when this is complete + JS.global[:document][:shoes_spec_passed] = result + elt = JS.global[:document].createElement("div") + elt[:className] = "minitest_result" + elt[:innerHTML] = "

#{result ? "passed" : "failed"}

" + JS.global[:document][:body].appendChild(elt) + + Shoes.APPS.each(&:destroy) # Need more recent version of Lacci with multi-app + end end attr_writer :document_root @@ -88,7 +108,7 @@ def bind(name, &block) # # @return [void] def request_redraw! - wrangler = DisplayService.instance.wrangler + wrangler = WebWrangler.instance if wrangler.is_running wrangler.replace(@document_root.to_html) end diff --git a/lib/space_shoes/guest/control_interface.rb b/lib/space_shoes/guest/control_interface.rb index 80f6294..4dbc6b8 100644 --- a/lib/space_shoes/guest/control_interface.rb +++ b/lib/space_shoes/guest/control_interface.rb @@ -16,10 +16,19 @@ class ControlInterface attr_writer :doc_root attr_reader :do_shutdown + class << self + attr_accessor :instance + end + # The control interface needs to see major system components to hook into their events def initialize log_init("SpaceShoes::ControlInterface") + if SpaceShoes::ControlInterface.instance + raise Shoes::Errors::TooManyInstancesError, "Cannot create multiple SpaceShoes::ControlInterface objects!" + end + SpaceShoes::ControlInterface.instance = self + @do_shutdown = false @event_handlers = {} (SUBSCRIBE_EVENTS | DISPATCH_EVENTS).each { |e| @event_handlers[e] = [] } diff --git a/lib/space_shoes/guest/display_service.rb b/lib/space_shoes/guest/display_service.rb index c7a3500..5bf126d 100644 --- a/lib/space_shoes/guest/display_service.rb +++ b/lib/space_shoes/guest/display_service.rb @@ -56,10 +56,10 @@ def create_display_drawable_for(drawable_class_name, drawable_id, properties, is display_app = SpaceShoes::App.new(properties) display_app.document_root = @doc_root - @control_interface = display_app.control_interface + @control_interface = SpaceShoes::ControlInterface.instance @control_interface.doc_root = @doc_root @app = @control_interface.app - @wrangler = @control_interface.wrangler + @wrangler = SpaceShoes::WebWrangler.instance set_drawable_pairing(drawable_id, display_app) diff --git a/lib/space_shoes/guest/shoes-spec.rb b/lib/space_shoes/guest/shoes-spec.rb new file mode 100644 index 0000000..46cb635 --- /dev/null +++ b/lib/space_shoes/guest/shoes-spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "minitest" + +module SpaceShoes + class ShoesSpec + def self.run_shoes_spec_test_code(code, class_name: nil, test_name: nil) + if @shoes_spec_init + raise Shoes::Errors::MultipleShoesSpecRunsError, "SpaceShoes can only run a single Shoes spec per process!" + end + @shoes_spec_init = true + + class_name ||= ENV["SHOES_MINITEST_CLASS_NAME"] || "TestShoesSpecCode" + test_name ||= ENV["SHOES_MINITEST_METHOD_NAME"] || "test_shoes_spec" + + test_class = Class.new(SpaceShoes::ShoesSpecTest) + Object.const_set(Scarpe::Components::StringHelpers.camelize(class_name), test_class) + test_name = "test_" + test_name unless test_name.start_with?("test_") + test_class.class_eval(<<~CLASS_EVAL) + def #{test_name} +#{code} + end + CLASS_EVAL + end + end +end + +# For now we send most events at the Scarpe layer. It would be quite sensible +# to send browser events rather than Scarpe events, but it will also take a lot +# more code to do it. We'll get there. + +class SpaceShoes::ShoesSpecTest < Minitest::Test + Shoes::Drawable.drawable_classes.each do |drawable_class| + finder_name = drawable_class.dsl_name + + define_method(finder_name) do |*args| + drawables = Shoes::App.find_drawables_by(drawable_class, *args) + + raise Shoes::Errors::MultipleDrawablesFoundError, "Found more than one #{finder_name} matching #{args.inspect}!" if drawables.size > 1 + raise Shoes::Errors::NoDrawablesFoundError, "Found no #{finder_name} matching #{args.inspect}!" if drawables.empty? + + SpaceShoes::ShoesSpecProxy.new(drawables[0]) + end + end + + def drawable(*specs) + drawables = Shoes::App.find_drawables_by(*specs) + raise Shoes::Errors::MultipleDrawablesFoundError, "Found more than one #{finder_name} matching #{args.inspect}!" if drawables.size > 1 + raise Shoes::Errors::NoDrawablesFoundError, "Found no #{finder_name} matching #{args.inspect}!" if drawables.empty? + SpaceShoes::ShoesSpecProxy.new(drawables[0]) + end +end + +class SpaceShoes::ShoesSpecProxy + attr_reader :obj + attr_reader :linkable_id + attr_reader :display + + def initialize(obj) + @obj = obj + @linkable_id = obj.linkable_id + @display = ::Shoes::DisplayService.display_service.query_display_drawable_for(obj.linkable_id) + end + + def method_missing(method, ...) + if @obj.respond_to?(method) + self.singleton_class.define_method(method) do |*args, **kwargs, &block| + @obj.send(method, *args, **kwargs, &block) + end + send(method, ...) + else + super # raise an exception + end + end + + JS_EVENTS = [:click, :hover, :leave] + JS_EVENTS.each do |event| + define_method("trigger_#{event}") do |*args, **kwargs| + ::Shoes::DisplayService.dispatch_event(event.to_s, @linkable_id, *args, **kwargs) + end + end + + def respond_to_missing?(method_name, include_private = false) + @obj.respond_to_missing?(method_name, include_private) + end +end diff --git a/lib/space_shoes/guest/subscription_item.rb b/lib/space_shoes/guest/subscription_item.rb index 80a9709..f3bfa17 100644 --- a/lib/space_shoes/guest/subscription_item.rb +++ b/lib/space_shoes/guest/subscription_item.rb @@ -9,20 +9,18 @@ def initialize(properties) send_self_event(*args, event_name: @shoes_api_name) end - @wrangler = DisplayService.instance.wrangler - case @shoes_api_name when "animate" frame_rate = (@args[0] || 10) @counter = 0 - @wrangler.periodic_code("animate_#{@shoes_linkable_id}", 1.0 / frame_rate) do + WebWrangler.instance.periodic_code("animate_#{@shoes_linkable_id}", 1.0 / frame_rate) do @counter += 1 send_self_event(@counter, event_name: @shoes_api_name) end when "every" delay = @args[0] @counter = 0 - @wrangler.periodic_code("every_#{@shoes_linkable_id}", delay) do + WebWrangler.instance.periodic_code("every_#{@shoes_linkable_id}", delay) do @counter += 1 send_self_event(@counter, event_name: @shoes_api_name) end diff --git a/lib/space_shoes/guest/web_wrangler.rb b/lib/space_shoes/guest/web_wrangler.rb index fed9d53..71cf95f 100644 --- a/lib/space_shoes/guest/web_wrangler.rb +++ b/lib/space_shoes/guest/web_wrangler.rb @@ -12,6 +12,10 @@ module SpaceShoes class WebWrangler include Shoes::Log + class << self + attr_accessor :instance + end + attr_reader :is_running attr_reader :is_terminated attr_reader :heartbeat # This is the heartbeat duration in seconds, usually fractional @@ -20,6 +24,11 @@ class WebWrangler def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) log_init("SpaceShoes::WebWrangler") + if SpaceShoes::WebWrangler.instance + raise Shoes::Errors::TooManyInstancesError, "Cannot create multiple SpaceShoes::WebWrangler objects!" + end + SpaceShoes::WebWrangler.instance = self + @log.debug("Creating WebWrangler...") @wasm = SpaceShoes::WasmCalls.new @@ -37,8 +46,10 @@ def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) # Ruby receives scarpeHeartbeat messages via the window library's main loop. # So this is a way for Ruby to be notified periodically, in time with that loop. @wasm.bind("scarpeHeartbeat") do - @heartbeat_handlers.each(&:call) - @control_interface.dispatch_event(:heartbeat) + unless @control_interface.do_shutdown + @heartbeat_handlers.each(&:call) + @control_interface.dispatch_event(:heartbeat) + end end js_interval = (heartbeat.to_f * 1_000.0).to_i @wasm.init("setInterval(scarpeHeartbeat,#{js_interval})") @@ -158,9 +169,6 @@ class ElementWrangler # @param selector [String|NilClass] the selector to get the DOM element(s) # @param multi [Boolean] whether the selector may return multiple DOM elements def initialize(html_id: nil, selector: nil, multi: false) - @webwrangler = SpaceShoes::DisplayService.instance.wrangler - raise Scarpe::MissingWranglerError, "Can't get WebWrangler!" unless @webwrangler - @html_id = html_id @multi = multi @selector = selector @@ -171,11 +179,9 @@ def initialize(html_id: nil, selector: nil, multi: false) def on_each(&block) if @multi items = JS.eval(@selector) - STDERR.puts "Multi selector: #{@selector.inspect} #{items.inspect}" items.each(&block) else item = JS.global[:document].getElementById(@html_id) - STDERR.puts "Single selector: #{@html_id.inspect} / #{item.inspect}" if item == JS_NULL yield(item) if item != JS_NULL end end diff --git a/packaging/Gemfile b/packaging/Gemfile index 3551a7d..881f798 100644 --- a/packaging/Gemfile +++ b/packaging/Gemfile @@ -4,5 +4,5 @@ gem "space_shoes", path: ".." gem "ruby_wasm", "~> 2.5" gem "js" -gem "lacci", "~>0.4.0" +gem "lacci", "~>0.4.0", github: "scarpe-team/scarpe", glob: "lacci/*.gemspec", branch: "multi_app" gem "scarpe-components", "~>0.4.0" diff --git a/packaging/Gemfile.lock b/packaging/Gemfile.lock index 295c0a7..e9b3504 100644 --- a/packaging/Gemfile.lock +++ b/packaging/Gemfile.lock @@ -1,16 +1,25 @@ +GIT + remote: https://github.com/scarpe-team/scarpe.git + revision: bddaf436b1152b2a8748c1d767db8cc31b52379a + branch: multi_app + glob: lacci/*.gemspec + specs: + lacci (0.4.0) + scarpe-components (~> 0.4.0) + PATH remote: .. specs: space_shoes (0.1.0) lacci (~> 0.4.0) + minitest (~> 5.22) scarpe-components (~> 0.4.0) GEM remote: https://rubygems.org/ specs: js (2.6.0) - lacci (0.4.0) - scarpe-components (~> 0.4.0) + minitest (5.22.3) ruby_wasm (2.6.0) ruby_wasm (2.6.0-aarch64-linux) ruby_wasm (2.6.0-aarch64-linux-musl) @@ -31,7 +40,7 @@ PLATFORMS DEPENDENCIES js - lacci (~> 0.4.0) + lacci (~> 0.4.0)! ruby_wasm (~> 2.5) scarpe-components (~> 0.4.0) space_shoes! diff --git a/space_shoes.gemspec b/space_shoes.gemspec index dc4420d..d7ad226 100644 --- a/space_shoes.gemspec +++ b/space_shoes.gemspec @@ -34,6 +34,9 @@ Gem::Specification.new do |spec| spec.add_dependency "lacci", "~>0.4.0" spec.add_dependency "scarpe-components", "~>0.4.0" + # For now, require as a direct dependency - needed so that minitest can run inside the browser in wasm + spec.add_dependency "minitest", "~>5.22" + # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html end diff --git a/test/cache/Gemfile b/test/cache/Gemfile index e87ae2a..ca2ab2b 100644 --- a/test/cache/Gemfile +++ b/test/cache/Gemfile @@ -2,4 +2,4 @@ source "https://rubygems.org" gem "space_shoes" gem "webrick" - +gem "lacci", github: "scarpe-team/scarpe", glob: "lacci/*.gemspec", branch: "multi_app" \ No newline at end of file diff --git a/test/cache/Gemfile.lock b/test/cache/Gemfile.lock index 589d9e7..c67c8ee 100644 --- a/test/cache/Gemfile.lock +++ b/test/cache/Gemfile.lock @@ -1,8 +1,15 @@ -GEM - remote: https://rubygems.org/ +GIT + remote: https://github.com/scarpe-team/scarpe.git + revision: bddaf436b1152b2a8748c1d767db8cc31b52379a + branch: multi_app + glob: lacci/*.gemspec specs: lacci (0.4.0) scarpe-components (~> 0.4.0) + +GEM + remote: https://rubygems.org/ + specs: scarpe-components (0.4.0) space_shoes (0.1.0) lacci @@ -14,6 +21,7 @@ PLATFORMS x86_64-darwin-22 DEPENDENCIES + lacci! space_shoes webrick diff --git a/test/cache/spacewalk.js b/test/cache/spacewalk.js index 5b6ba82..0d5c2f4 100644 --- a/test/cache/spacewalk.js +++ b/test/cache/spacewalk.js @@ -21,6 +21,17 @@ vm.eval(` `); async function runShoesApps(vm) { + // If there's a Shoes-Spec script, make sure it gets loaded before the + // apps. + const tag = document.querySelector('script[type="text/shoes-spec"]'); + if(tag) { + vm.eval(` + test_elt = JS.global[:document].querySelector('script[type="text/shoes-spec"]') + test_code = test_elt[:innerText] + Shoes::Spec.instance.run_shoes_spec_test_code test_code + `); + } + const tags = document.querySelectorAll('script[type="text/ruby"]'); // Get Ruby scripts in parallel. @@ -32,31 +43,15 @@ async function runShoesApps(vm) { for await (const script of promisingRubyScripts) { if (script) { const { scriptContent, evalStyle } = script; - switch (evalStyle) { - case "async": - vm.evalAsync(scriptContent); - break; - case "sync": - vm.eval(scriptContent); - break; - } + vm.eval(scriptContent); + break; } } -} -function deriveEvalStyle(tag) { - const rawEvalStyle = tag.getAttribute("data-eval") || "sync"; - if (rawEvalStyle !== "async" && rawEvalStyle !== "sync") { - console.warn( - `data-eval attribute of script tag must be "async" or "sync". ${rawEvalStyle} is ignored and "sync" is used instead.`, - ); - return "sync"; - } - return rawEvalStyle; -}; +} async function loadScriptAsync(tag) { - const evalStyle = deriveEvalStyle(tag); + const evalStyle = "sync"; if (tag.hasAttribute("src")) { const url = tag.getAttribute("src"); const response = await fetch(url); diff --git a/test/cache/sspec_test.html b/test/cache/sspec_test.html new file mode 100644 index 0000000..1b4c65d --- /dev/null +++ b/test/cache/sspec_test.html @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/test/test_shoes_spec.rb b/test/test_shoes_spec.rb new file mode 100644 index 0000000..1a4bce0 --- /dev/null +++ b/test/test_shoes_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +# Use Capybara and Selenium to run ShoesSpec tests +class Scarpe::TestShoesSpecInfrastructure < SpaceShoesPackagedTest + def test_basic_sspec_test_succeeds + with_app("/sspec_test.html") do + assert_selector("div.minitest_result", wait: 5) + result = page.evaluate_script('document.shoes_spec_passed') + assert_equal true, result + end + end +end